diff --git a/lib/updaters/index.js b/lib/updaters/index.js index 84ed1a0fa..0c4293d93 100644 --- a/lib/updaters/index.js +++ b/lib/updaters/index.js @@ -1,13 +1,17 @@ const path = require('path') const JSON_BUMP_FILES = require('../../defaults').bumpFiles +const updatersByType = { + json: require('./types/json'), + 'plain-text': require('./types/plain-text') +} const PLAIN_TEXT_BUMP_FILES = ['VERSION.txt', 'version.txt'] function getUpdaterByType (type) { - try { - return require(`./types/${type}`) - } catch (e) { - throw Error(`Unable to locate updated for provided type (${type}).`) + const updater = updatersByType[type] + if (!updater) { + throw Error(`Unable to locate updater for provided type (${type}).`) } + return updater } function getUpdaterByFilename (filename) { diff --git a/package.json b/package.json index fbae24546..0d8436aeb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "fix": "eslint . --fix", "posttest": "eslint .", - "test": "nyc mocha --timeout=30000 test.js", + "test": "nyc mocha --timeout=30000", + "test:unit": "mocha --exclude test/git.spec.js", "release": "bin/cli.js" }, "nyc": { @@ -61,9 +62,10 @@ "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", "mocha": "^8.0.0", - "mock-git": "^2.0.0", + "mock-fs": "^4.12.0", "mockery": "^2.1.0", "nyc": "^14.1.1", - "shelljs": "^0.8.4" + "shelljs": "^0.8.4", + "std-mocks": "^1.0.1" } } diff --git a/test.js b/test.js deleted file mode 100644 index 83627fa66..000000000 --- a/test.js +++ /dev/null @@ -1,1338 +0,0 @@ -/* global describe it beforeEach afterEach */ - -'use strict' - -const shell = require('shelljs') -const fs = require('fs') -const path = require('path') -const stream = require('stream') -const mockGit = require('mock-git') -const mockery = require('mockery') -const semver = require('semver') -const formatCommitMessage = require('./lib/format-commit-message') -const cli = require('./command') -const standardVersion = require('./index') - -const isWindows = process.platform === 'win32' - -require('chai').should() - -const cliPath = path.resolve(__dirname, './bin/cli.js') - -function branch (branch) { - shell.exec('git branch ' + branch) -} - -function checkout (branch) { - shell.exec('git checkout ' + branch) -} - -function commit (msg) { - shell.exec('git commit --allow-empty -m"' + msg + '"') -} - -function merge (msg, branch) { - shell.exec('git merge --no-ff -m"' + msg + '" ' + branch) -} - -function execCli (argString) { - return shell.exec('node ' + cliPath + (argString != null ? ' ' + argString : '')) -} - -function execCliAsync (argString) { - return standardVersion(cli.parse('standard-version ' + argString + ' --silent')) -} - -function writePackageJson (version, option) { - option = option || {} - const pkg = Object.assign(option, { version: version }) - fs.writeFileSync('package.json', JSON.stringify(pkg), 'utf-8') -} - -function writeBowerJson (version, option) { - option = option || {} - const bower = Object.assign(option, { version: version }) - fs.writeFileSync('bower.json', JSON.stringify(bower), 'utf-8') -} - -function writeManifestJson (version, option) { - option = option || {} - const manifest = Object.assign(option, { version: version }) - fs.writeFileSync('manifest.json', JSON.stringify(manifest), 'utf-8') -} - -function writeNpmShrinkwrapJson (version, option) { - option = option || {} - const shrinkwrap = Object.assign(option, { version: version }) - fs.writeFileSync('npm-shrinkwrap.json', JSON.stringify(shrinkwrap), 'utf-8') -} - -function writePackageLockJson (version, option) { - option = option || {} - const pkgLock = Object.assign(option, { version: version }) - fs.writeFileSync('package-lock.json', JSON.stringify(pkgLock), 'utf-8') -} - -function writeGitPreCommitHook () { - fs.writeFileSync('.git/hooks/pre-commit', '#!/bin/sh\necho "precommit ran"\nexit 1', 'utf-8') - fs.chmodSync('.git/hooks/pre-commit', '755') -} - -function writePostBumpHook (causeError) { - writeHook('postbump', causeError) -} - -function writeHook (hookName, causeError, script) { - shell.mkdir('-p', 'scripts') - let content = script || 'console.error("' + hookName + ' ran")' - content += causeError ? '\nthrow new Error("' + hookName + '-failure")' : '' - fs.writeFileSync('scripts/' + hookName + '.js', content, 'utf-8') - fs.chmodSync('scripts/' + hookName + '.js', '755') -} - -function initInTempFolder () { - shell.rm('-rf', 'tmp') - shell.config.silent = true - shell.mkdir('tmp') - shell.cd('tmp') - shell.exec('git init') - shell.exec('git config commit.gpgSign false') - commit('root-commit') - writePackageJson('1.0.0') -} - -function finishTemp () { - shell.cd('../') - shell.rm('-rf', 'tmp') -} - -function getPackageVersion () { - return JSON.parse(fs.readFileSync('package.json', 'utf-8')).version -} - -describe('format-commit-message', function () { - it('works for no {{currentTag}}', function () { - formatCommitMessage('chore(release): 1.0.0', '1.0.0').should.equal('chore(release): 1.0.0') - }) - it('works for one {{currentTag}}', function () { - formatCommitMessage('chore(release): {{currentTag}}', '1.0.0').should.equal('chore(release): 1.0.0') - }) - it('works for two {{currentTag}}', function () { - formatCommitMessage('chore(release): {{currentTag}} \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v{{currentTag}}/CHANGELOG.md', '1.0.0').should.equal('chore(release): 1.0.0 \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v1.0.0/CHANGELOG.md') - }) -}) - -describe('cli', function () { - beforeEach(initInTempFolder) - afterEach(finishTemp) - - describe('CHANGELOG.md does not exist', function () { - it('populates changelog with commits since last tag by default', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('fix: patch release') - - execCli().code.should.equal(0) - - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/patch release/) - content.should.not.match(/first commit/) - }) - - it('includes all commits if --first-release is true', function () { - writePackageJson('1.0.1') - - commit('feat: first commit') - commit('fix: patch release') - execCli('--first-release').code.should.equal(0) - - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/patch release/) - content.should.match(/first commit/) - shell.exec('git tag').stdout.should.match(/1\.0\.1/) - }) - - it('skipping changelog will not create a changelog file', function () { - writePackageJson('1.0.0') - - commit('feat: first commit') - return execCliAsync('--skip.changelog true') - .then(function () { - getPackageVersion().should.equal('1.1.0') - let fileNotFound = false - try { - fs.readFileSync('CHANGELOG.md', 'utf-8') - } catch (err) { - fileNotFound = true - } - - fileNotFound.should.equal(true) - }) - }) - }) - - describe('CHANGELOG.md exists', function () { - it('appends the new release above the last release, removing the old header (legacy format)', function () { - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('fix: patch release') - - execCli().code.should.equal(0) - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/1\.0\.1/) - content.should.not.match(/legacy header format/) - }) - - // TODO: we should use snapshots which are easier to update than large - // string assertions; we should also consider not using the CLI which - // is slower than calling standard-version directly. - it('appends the new release above the last release, removing the old header (new format)', function () { - // we don't create a package.json, so no {{host}} and {{repo}} tag - // will be populated, let's use a compareUrlFormat without these. - const cliArgs = '--compareUrlFormat=/compare/{{previousTag}}...{{currentTag}}' - - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('fix: patch release') - - execCli(cliArgs).code.should.equal(0) - let content = fs.readFileSync('CHANGELOG.md', 'utf-8') - - // remove commit hashes and dates to make testing against a static string easier: - content = content.replace(/patch release [0-9a-f]{6,8}/g, 'patch release ABCDEFXY').replace(/\([0-9]{4}-[0-9]{2}-[0-9]{2}\)/g, '(YYYY-MM-DD)') - content.should.equal('# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n\n### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n') - - commit('fix: another patch release') - // we've populated no package.json, so no {{host}} and - execCli(cliArgs).code.should.equal(0) - content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content = content.replace(/patch release [0-9a-f]{6,8}/g, 'patch release ABCDEFXY').replace(/\([0-9]{4}-[0-9]{2}-[0-9]{2}\)/g, '(YYYY-MM-DD)') - content.should.equal('# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n\n### [1.0.2](/compare/v1.0.1...v1.0.2) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* another patch release ABCDEFXY\n\n### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n') - }) - - it('commits all staged files', function () { - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('fix: patch release') - - fs.writeFileSync('STUFF.md', 'stuff\n', 'utf-8') - - shell.exec('git add STUFF.md') - - execCli('--commit-all').code.should.equal(0) - - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - const status = shell.exec('git status --porcelain') // see http://unix.stackexchange.com/questions/155046/determine-if-git-working-directory-is-clean-from-a-script - - status.should.equal('') - status.should.not.match(/STUFF.md/) - - content.should.match(/1\.0\.1/) - content.should.not.match(/legacy header format/) - }) - - it('[DEPRECATED] (--changelogHeader) allows for a custom changelog header', function () { - fs.writeFileSync('CHANGELOG.md', '', 'utf-8') - commit('feat: first commit') - execCli('--changelogHeader="# Pork Chop Log"').code.should.equal(0) - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/# Pork Chop Log/) - }) - - it('[DEPRECATED] (--changelogHeader) exits with error if changelog header matches last version search regex', function () { - fs.writeFileSync('CHANGELOG.md', '', 'utf-8') - commit('feat: first commit') - execCli('--changelogHeader="## 3.0.2"').code.should.equal(1) - }) - }) - - // TODO: investigate why mock-git does not play well with execFile on Windows. - if (!isWindows) { - describe('with mocked git', function () { - it('--sign signs the commit and tag', function () { - // mock git with file that writes args to gitcapture.log - return mockGit('require("fs").appendFileSync("gitcapture.log", JSON.stringify(process.argv.splice(2)) + "\\n")') - .then(function (unmock) { - execCli('--sign').code.should.equal(0) - - const captured = shell.cat('gitcapture.log').stdout.split('\n').map(function (line) { - return line ? JSON.parse(line) : line - }) - /* eslint-disable no-useless-escape */ - captured[captured.length - 4].should.deep.equal(['commit', '-S', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1']) - captured[captured.length - 3].should.deep.equal(['tag', '-s', 'v1.0.1', '-m', 'chore(release): 1.0.1']) - /* eslint-enable no-useless-escape */ - unmock() - }) - }) - - it('exits with error code if git commit fails', function () { - // mock git by throwing on attempt to commit - return mockGit('console.error("commit yourself"); process.exit(128);', 'commit') - .then(function (unmock) { - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/commit yourself/) - - unmock() - }) - }) - - it('exits with error code if git add fails', function () { - // mock git by throwing on attempt to add - return mockGit('console.error("addition is hard"); process.exit(128);', 'add') - .then(function (unmock) { - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/addition is hard/) - - unmock() - }) - }) - - it('exits with error code if git tag fails', function () { - // mock git by throwing on attempt to commit - return mockGit('console.error("tag, you\'re it"); process.exit(128);', 'tag') - .then(function (unmock) { - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/tag, you're it/) - - unmock() - }) - }) - - it('doesn\'t fail fast on stderr output from git', function () { - // mock git by throwing on attempt to commit - return mockGit('console.error("haha, kidding, this is just a warning"); process.exit(0);', 'add') - .then(function (unmock) { - writePackageJson('1.0.0') - - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/haha, kidding, this is just a warning/) - - unmock() - }) - }) - }) - } - - describe('lifecycle scripts', () => { - describe('prerelease hook', function () { - it('should run the prerelease hook when provided', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - prerelease: 'node scripts/prerelease' - } - } - }) - writeHook('prerelease') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(0) - result.stderr.should.match(/prerelease ran/) - }) - - it('should abort if the hook returns a non-zero exit code', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - prerelease: 'node scripts/prerelease && exit 1' - } - } - }) - writeHook('prerelease') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(1) - result.stderr.should.match(/prerelease ran/) - }) - }) - - describe('prebump hook', function () { - it('should allow prebump hook to return an alternate version #', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - prebump: 'node scripts/prebump' - } - } - }) - writeHook('prebump', false, 'console.log("9.9.9")') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.stdout.should.match(/9\.9\.9/) - result.code.should.equal(0) - }) - }) - - describe('postbump hook', function () { - it('should run the postbump hook when provided', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - postbump: 'node scripts/postbump' - } - } - }) - writePostBumpHook() - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(0) - result.stderr.should.match(/postbump ran/) - }) - - it('should run the postbump and exit with error when postbump fails', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - postbump: 'node scripts/postbump' - } - } - }) - writePostBumpHook(true) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(1) - result.stderr.should.match(/postbump-failure/) - }) - }) - - describe('precommit hook', function () { - it('should run the precommit hook when provided via .versionrc.json (#371)', function () { - fs.writeFileSync('.versionrc.json', JSON.stringify({ - scripts: { - precommit: 'node scripts/precommit' - } - }), 'utf-8') - - writeHook('precommit') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - commit('feat: first commit') - const result = execCli() - result.code.should.equal(0) - result.stderr.should.match(/precommit ran/) - }) - - it('should run the precommit hook when provided', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - precommit: 'node scripts/precommit' - } - } - }) - writeHook('precommit') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(0) - result.stderr.should.match(/precommit ran/) - }) - - it('should run the precommit hook and exit with error when precommit fails', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - precommit: 'node scripts/precommit' - } - } - }) - writeHook('precommit', true) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(1) - result.stderr.should.match(/precommit-failure/) - }) - - it('should allow an alternate commit message to be provided by precommit script', function () { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: { - precommit: 'node scripts/precommit' - } - } - }) - writeHook('precommit', false, 'console.log("releasing %s delivers #222")') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - const result = execCli('--patch') - result.code.should.equal(0) - shell.exec('git log --oneline -n1').should.match(/delivers #222/) - }) - }) - }) - - describe('pre-release', function () { - it('works fine without specifying a tag id when prereleasing', function () { - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - return execCliAsync('--prerelease') - .then(function () { - // it's a feature commit, so it's minor type - getPackageVersion().should.equal('1.1.0-0') - }) - }) - - it('advises use of --tag prerelease for publishing to npm', function () { - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - execCli('--prerelease').stdout.should.include('--tag prerelease') - }) - - it('advises use of --tag alpha for publishing to npm when tagging alpha', function () { - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - execCli('--prerelease alpha').stdout.should.include('--tag alpha') - }) - - it('does not advise use of --tag prerelease for private modules', function () { - writePackageJson('1.0.0', { private: true }) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('feat: first commit') - execCli('--prerelease').stdout.should.not.include('--tag prerelease') - }) - }) - - describe('manual-release', function () { - it('throws error when not specifying a release type', function () { - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first commit') - execCli('--release-as').code.should.above(0) - }) - - describe('release-types', function () { - const regularTypes = ['major', 'minor', 'patch'] - - regularTypes.forEach(function (type) { - it('creates a ' + type + ' release', function () { - const originVer = '1.0.0' - writePackageJson(originVer) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first commit') - - return execCliAsync('--release-as ' + type) - .then(function () { - const version = { - major: semver.major(originVer), - minor: semver.minor(originVer), - patch: semver.patch(originVer) - } - - version[type] += 1 - - getPackageVersion().should.equal(version.major + '.' + version.minor + '.' + version.patch) - }) - }) - }) - - // this is for pre-releases - regularTypes.forEach(function (type) { - it('creates a pre' + type + ' release', function () { - const originVer = '1.0.0' - writePackageJson(originVer) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first commit') - - return execCliAsync('--release-as ' + type + ' --prerelease ' + type) - .then(function () { - const version = { - major: semver.major(originVer), - minor: semver.minor(originVer), - patch: semver.patch(originVer) - } - - version[type] += 1 - - getPackageVersion().should.equal(version.major + '.' + version.minor + '.' + version.patch + '-' + type + '.0') - }) - }) - }) - }) - - describe('release-as-exact', function () { - it('releases as v100.0.0', function () { - const originVer = '1.0.0' - writePackageJson(originVer) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first commit') - - return execCliAsync('--release-as v100.0.0') - .then(function () { - getPackageVersion().should.equal('100.0.0') - }) - }) - - it('releases as 200.0.0-amazing', function () { - const originVer = '1.0.0' - writePackageJson(originVer) - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first commit') - - return execCliAsync('--release-as 200.0.0-amazing') - .then(function () { - getPackageVersion().should.equal('200.0.0-amazing') - }) - }) - }) - - it('creates a prerelease with a new minor version after two prerelease patches', function () { - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') - - commit('fix: first patch') - return execCliAsync('--release-as patch --prerelease dev') - .then(function () { - getPackageVersion().should.equal('1.0.1-dev.0') - }) - - // second - .then(function () { - commit('fix: second patch') - return execCliAsync('--prerelease dev') - }) - .then(function () { - getPackageVersion().should.equal('1.0.1-dev.1') - }) - - // third - .then(function () { - commit('feat: first new feat') - return execCliAsync('--release-as minor --prerelease dev') - }) - .then(function () { - getPackageVersion().should.equal('1.1.0-dev.0') - }) - - .then(function () { - commit('fix: third patch') - return execCliAsync('--release-as minor --prerelease dev') - }) - .then(function () { - getPackageVersion().should.equal('1.1.0-dev.1') - }) - - .then(function () { - commit('fix: forth patch') - return execCliAsync('--prerelease dev') - }) - .then(function () { - getPackageVersion().should.equal('1.1.0-dev.2') - }) - }) - }) - - it('handles commit messages longer than 80 characters', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('fix: this is my fairly long commit message which is testing whether or not we allow for long commit messages') - - execCli().code.should.equal(0) - - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/this is my fairly long commit message which is testing whether or not we allow for long commit messages/) - }) - - it('formats the commit and tag messages appropriately', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - - execCli().code.should.equal(0) - - // check last commit message - shell.exec('git log --oneline -n1').stdout.should.match(/chore\(release\): 1\.1\.0/) - // check annotated tag message - shell.exec('git tag -l -n1 v1.1.0').stdout.should.match(/chore\(release\): 1\.1\.0/) - }) - - it('appends line feed at end of package.json', function () { - execCli().code.should.equal(0) - - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', ' "version": "1.0.1"', '}', ''].join('\n')) - }) - - it('preserves indentation of tabs in package.json', function () { - const indentation = '\t' - const newPkgJson = ['{', indentation + '"version": "1.0.0"', '}', ''].join('\n') - fs.writeFileSync('package.json', newPkgJson, 'utf-8') - - execCli().code.should.equal(0) - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', indentation + '"version": "1.0.1"', '}', ''].join('\n')) - }) - - it('preserves indentation of spaces in package.json', function () { - const indentation = ' ' - const newPkgJson = ['{', indentation + '"version": "1.0.0"', '}', ''].join('\n') - fs.writeFileSync('package.json', newPkgJson, 'utf-8') - - execCli().code.should.equal(0) - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', indentation + '"version": "1.0.1"', '}', ''].join('\n')) - }) - - it('preserves line feed in package.json', function () { - const newPkgJson = ['{', ' "version": "1.0.0"', '}', ''].join('\n') - fs.writeFileSync('package.json', newPkgJson, 'utf-8') - - execCli().code.should.equal(0) - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', ' "version": "1.0.1"', '}', ''].join('\n')) - }) - - it('preserves carriage return + line feed in package.json', function () { - const newPkgJson = ['{', ' "version": "1.0.0"', '}', ''].join('\r\n') - fs.writeFileSync('package.json', newPkgJson, 'utf-8') - - execCli().code.should.equal(0) - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', ' "version": "1.0.1"', '}', ''].join('\r\n')) - }) - - it('does not run git hooks if the --no-verify flag is passed', function () { - writeGitPreCommitHook() - - commit('feat: first commit') - execCli('--no-verify').code.should.equal(0) - commit('feat: second commit') - execCli('-n').code.should.equal(0) - }) - - it('does not print output when the --silent flag is passed', function () { - const result = execCli('--silent') - result.code.should.equal(0) - result.stdout.should.equal('') - result.stderr.should.equal('') - }) - - it('does not display `npm publish` if the package is private', function () { - writePackageJson('1.0.0', { private: true }) - - const result = execCli() - result.code.should.equal(0) - result.stdout.should.not.match(/npm publish/) - }) - - it('does not display `all staged files` without the --commit-all flag', function () { - const result = execCli() - result.code.should.equal(0) - result.stdout.should.not.match(/and all staged files/) - }) - - it('does display `all staged files` if the --commit-all flag is passed', function () { - const result = execCli('--commit-all') - result.code.should.equal(0) - result.stdout.should.match(/and all staged files/) - }) - - it('includes merge commits', function () { - const branchName = 'new-feature' - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - branch(branchName) - checkout(branchName) - commit('Implementing new feature') - checkout('master') - merge('feat: new feature from branch', branchName) - - execCli().code.should.equal(0) - - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/new feature from branch/) - - const pkgJson = fs.readFileSync('package.json', 'utf-8') - pkgJson.should.equal(['{', ' "version": "1.1.0"', '}', ''].join('\n')) - }) - - it('exits with error code if "scripts" is not an object', () => { - writePackageJson('1.0.0', { - 'standard-version': { - scripts: 'echo hello' - } - }) - - commit('feat: first commit') - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/scripts must be an object/) - }) - - it('exits with error code if "skip" is not an object', () => { - writePackageJson('1.0.0', { - 'standard-version': { - skip: true - } - }) - - commit('feat: first commit') - const result = execCli() - result.code.should.equal(1) - result.stderr.should.match(/skip must be an object/) - }) -}) - -describe('standard-version', function () { - beforeEach(initInTempFolder) - afterEach(finishTemp) - - describe('with mocked conventionalRecommendedBump', function () { - beforeEach(function () { - mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) - mockery.registerMock('conventional-recommended-bump', function (_, cb) { - cb(new Error('bump err')) - }) - }) - - afterEach(function () { - mockery.deregisterMock('conventional-recommended-bump') - mockery.disable() - }) - - it('should exit on bump error', function (done) { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - - require('./index')({ silent: true }) - .catch((err) => { - err.message.should.match(/bump err/) - done() - }) - }) - }) - - describe('with mocked conventionalChangelog', function () { - beforeEach(function () { - mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) - mockery.registerMock('conventional-changelog', function () { - const readable = new stream.Readable({ objectMode: true }) - readable._read = function () { - } - setImmediate(readable.emit.bind(readable), 'error', new Error('changelog err')) - return readable - }) - }) - - afterEach(function () { - mockery.deregisterMock('conventional-changelog') - mockery.disable() - }) - - it('should exit on changelog error', function (done) { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - - require('./index')({ silent: true }) - .catch((err) => { - err.message.should.match(/changelog err/) - return done() - }) - }) - }) - - it('formats the commit and tag messages appropriately', function (done) { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - - require('./index')({ silent: true }) - .then(() => { - // check last commit message - shell.exec('git log --oneline -n1').stdout.should.match(/chore\(release\): 1\.1\.0/) - // check annotated tag message - shell.exec('git tag -l -n1 v1.1.0').stdout.should.match(/chore\(release\): 1\.1\.0/) - done() - }) - }) - - describe('without a package file to bump', function () { - it('should exit with error', function () { - shell.rm('package.json') - return require('./index')({ - silent: true, - gitTagFallback: false - }) - .catch((err) => { - err.message.should.equal('no package file found') - }) - }) - }) - - describe('bower.json support', function () { - beforeEach(function () { - writeBowerJson('1.0.0') - }) - - it('bumps version # in bower.json', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal('1.1.0') - getPackageVersion().should.equal('1.1.0') - }) - }) - }) - - describe('manifest.json support', function () { - beforeEach(function () { - writeManifestJson('1.0.0') - }) - - it('bumps version # in manifest.json', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('manifest.json', 'utf-8')).version.should.equal('1.1.0') - getPackageVersion().should.equal('1.1.0') - }) - }) - }) - - describe('custom `bumpFiles` support', function () { - it('mix.exs + version.txt', function () { - // @todo This file path is relative to the `tmp` directory, which is a little confusing - fs.copyFileSync('../test/mocks/mix.exs', 'mix.exs') - fs.copyFileSync('../test/mocks/version.txt', 'version.txt') - fs.copyFileSync('../test/mocks/updater/customer-updater.js', 'custom-updater.js') - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ - silent: true, - bumpFiles: [ - 'version.txt', - { - filename: 'mix.exs', - updater: 'custom-updater.js' - } - ] - }) - .then(() => { - fs.readFileSync('mix.exs', 'utf-8').should.contain('version: "1.1.0"') - fs.readFileSync('version.txt', 'utf-8').should.equal('1.1.0') - }) - }) - - it('bumps a custom `plain-text` file', function () { - fs.copyFileSync('../test/mocks/VERSION-1.0.0.txt', 'VERSION_TRACKER.txt') - commit('feat: first commit') - return require('./index')({ - silent: true, - bumpFiles: [ - { - filename: 'VERSION_TRACKER.txt', - type: 'plain-text' - } - ] - }) - .then(() => { - fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('1.1.0') - }) - }) - }) - - describe('custom `packageFiles` support', function () { - it('reads and writes to a custom `plain-text` file', function () { - fs.copyFileSync('../test/mocks/VERSION-6.3.1.txt', 'VERSION_TRACKER.txt') - commit('feat: yet another commit') - return require('./index')({ - silent: true, - packageFiles: [ - { - filename: 'VERSION_TRACKER.txt', - type: 'plain-text' - } - ], - bumpFiles: [ - { - filename: 'VERSION_TRACKER.txt', - type: 'plain-text' - } - ] - }) - .then(() => { - fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('6.4.0') - }) - }) - }) - - describe('npm-shrinkwrap.json support', function () { - beforeEach(function () { - writeNpmShrinkwrapJson('1.0.0') - }) - - it('bumps version # in npm-shrinkwrap.json', function (done) { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('npm-shrinkwrap.json', 'utf-8')).version.should.equal('1.1.0') - getPackageVersion().should.equal('1.1.0') - return done() - }) - }) - }) - - describe('package-lock.json support', function () { - beforeEach(function () { - writePackageLockJson('1.0.0') - fs.writeFileSync('.gitignore', '', 'utf-8') - }) - - it('bumps version # in package-lock.json', function () { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('package-lock.json', 'utf-8')).version.should.equal('1.1.0') - getPackageVersion().should.equal('1.1.0') - }) - }) - }) - - describe('dry-run', function () { - it('skips all non-idempotent steps', function (done) { - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - execCli('--dry-run').stdout.should.match(/### Features/) - shell.exec('git log --oneline -n1').stdout.should.match(/feat: new feature!/) - shell.exec('git tag').stdout.should.match(/1\.0\.0/) - getPackageVersion().should.equal('1.0.0') - return done() - }) - }) - - describe('skip', () => { - it('allows bump and changelog generation to be skipped', function () { - const changelogContent = 'legacy header format\n' - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', changelogContent, 'utf-8') - - commit('feat: first commit') - return execCliAsync('--skip.bump true --skip.changelog true') - .then(function () { - getPackageVersion().should.equal('1.0.0') - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.equal(changelogContent) - }) - }) - - it('allows the commit phase to be skipped', function () { - const changelogContent = 'legacy header format\n' - writePackageJson('1.0.0') - fs.writeFileSync('CHANGELOG.md', changelogContent, 'utf-8') - - commit('feat: new feature from branch') - return execCliAsync('--skip.commit true') - .then(function () { - getPackageVersion().should.equal('1.1.0') - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/new feature from branch/) - // check last commit message - shell.exec('git log --oneline -n1').stdout.should.match(/feat: new feature from branch/) - }) - }) - }) - - describe('.gitignore', () => { - beforeEach(function () { - writeBowerJson('1.0.0') - }) - - it('does not update files present in .gitignore', () => { - fs.writeFileSync('.gitignore', 'bower.json', 'utf-8') - - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal('1.0.0') - getPackageVersion().should.equal('1.1.0') - }) - }) - }) - - describe('.gitignore', () => { - beforeEach(function () { - writeBowerJson('1.0.0') - }) - - it('does not update files present in .gitignore', () => { - fs.writeFileSync('.gitignore', 'bower.json', 'utf-8') - - commit('feat: first commit') - shell.exec('git tag -a v1.0.0 -m "my awesome first release"') - commit('feat: new feature!') - return require('./index')({ silent: true }) - .then(() => { - JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal('1.0.0') - getPackageVersion().should.equal('1.1.0') - }) - }) - }) - - describe('gitTagFallback', () => { - it('defaults to 1.0.0 if no tags in git history', () => { - shell.rm('package.json') - commit('feat: first commit') - return require('./index')({ silent: true }) - .then(() => { - const output = shell.exec('git tag') - output.stdout.should.include('v1.1.0') - }) - }) - - it('bases version on last tag, if tags are found', () => { - shell.rm('package.json') - shell.exec('git tag -a v5.0.0 -m "a release"') - shell.exec('git tag -a v3.0.0 -m "another release"') - commit('feat: another commit') - return require('./index')({ silent: true }) - .then(() => { - const output = shell.exec('git tag') - output.stdout.should.include('v5.1.0') - }) - }) - - it('does not display `npm publish` if there is no package.json', function () { - shell.rm('package.json') - const result = execCli() - result.code.should.equal(0) - result.stdout.should.not.match(/npm publish/) - }) - }) - - describe('configuration', () => { - it('reads config from package.json', function () { - writePackageJson('1.0.0', { - repository: { - url: 'git+https://company@scm.org/office/app.git' - }, - 'standard-version': { - issueUrlFormat: 'https://standard-version.company.net/browse/{{id}}' - } - }) - commit('feat: another commit addresses issue #1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('https://standard-version.company.net/browse/1') - }) - - it('reads config from .versionrc', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync('.versionrc', JSON.stringify({ - issueUrlFormat: 'http://www.foo.com/{{id}}' - }), 'utf-8') - commit('feat: another commit addresses issue #1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.foo.com/1') - }) - - it('reads config from .versionrc.json', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync('.versionrc.json', JSON.stringify({ - issueUrlFormat: 'http://www.foo.com/{{id}}' - }), 'utf-8') - commit('feat: another commit addresses issue #1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.foo.com/1') - }) - - it('evaluates a config-function from .versionrc.js', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync( - '.versionrc.js', - `module.exports = function() { - return { - issueUrlFormat: 'http://www.versionrc.js/function/{{id}}' - } - }`, - 'utf-8' - ) - commit('feat: another commit addresses issue #1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.versionrc.js/function/1') - }) - - it('evaluates a config-object from .versionrc.js', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync( - '.versionrc.js', - `module.exports = { - issueUrlFormat: 'http://www.versionrc.js/object/{{id}}' - }`, - 'utf-8' - ) - commit('feat: another commit addresses issue #1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.versionrc.js/object/1') - }) - - it('throws an error when a non-object is returned from .versionrc.js', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync( - '.versionrc.js', - 'module.exports = 3', - 'utf-8' - ) - commit('feat: another commit addresses issue #1') - execCli().code.should.equal(1) - }) - - it('.versionrc : releaseCommitMessageFormat', function () { - // write configuration that overrides default issue - // URL format. - fs.writeFileSync('.versionrc', JSON.stringify({ - releaseCommitMessageFormat: 'This commit represents release: {{currentTag}}' - }), 'utf-8') - commit('feat: another commit addresses issue #1') - execCli() - shell.exec('git log --oneline -n1').should.include('This commit represents release: 1.1.0') - }) - - it('--releaseCommitMessageFormat', function () { - commit('feat: another commit addresses issue #1') - execCli('--releaseCommitMessageFormat="{{currentTag}} is the version."') - shell.exec('git log --oneline -n1').should.include('1.1.0 is the version.') - }) - - it('.versionrc : issuePrefixes', function () { - // write configuration that overrides default issuePrefixes - // and reference prefix in issue URL format. - fs.writeFileSync('.versionrc', JSON.stringify({ - issueUrlFormat: 'http://www.foo.com/{{prefix}}{{id}}', - issuePrefixes: ['ABC-'] - }), 'utf-8') - commit('feat: another commit addresses issue ABC-1') - execCli() - // CHANGELOG should have the new issue URL format. - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.foo.com/ABC-1') - }) - - it('--header', function () { - fs.writeFileSync('CHANGELOG.md', '', 'utf-8') - commit('feat: first commit') - execCli('--header="# Welcome to our CHANGELOG.md"').code.should.equal(0) - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.match(/# Welcome to our CHANGELOG.md/) - }) - - it('--issuePrefixes and --issueUrlFormat', function () { - commit('feat: another commit addresses issue ABC-1') - execCli('--issuePrefixes="ABC-" --issueUrlFormat="http://www.foo.com/{{prefix}}{{id}}"') - const content = fs.readFileSync('CHANGELOG.md', 'utf-8') - content.should.include('http://www.foo.com/ABC-1') - }) - - it('[LEGACY] supports --message (and single %s replacement)', function () { - commit('feat: another commit addresses issue #1') - execCli('--message="V:%s"') - shell.exec('git log --oneline -n1').should.include('V:1.1.0') - }) - - it('[LEGACY] supports -m (and multiple %s replacements)', function () { - commit('feat: another commit addresses issue #1') - execCli('--message="V:%s is the %s."') - shell.exec('git log --oneline -n1').should.include('V:1.1.0 is the 1.1.0.') - }) - }) - - describe('pre-major', () => { - it('bumps the minor rather than major, if version < 1.0.0', function () { - writePackageJson('0.5.0', { - repository: { - url: 'https://github.com/yargs/yargs.git' - } - }) - commit('feat!: this is a breaking change') - execCli() - getPackageVersion().should.equal('0.6.0') - }) - - it('bumps major if --release-as=major specified, if version < 1.0.0', function () { - writePackageJson('0.5.0', { - repository: { - url: 'https://github.com/yargs/yargs.git' - } - }) - commit('feat!: this is a breaking change') - execCli('-r major') - getPackageVersion().should.equal('1.0.0') - }) - }) -}) - -describe('GHSL-2020-111', function () { - beforeEach(initInTempFolder) - afterEach(finishTemp) - it('does not allow command injection via basic configuration', function () { - return standardVersion({ - silent: true, - noVerify: true, - infile: 'foo.txt', - releaseCommitMessageFormat: 'bla `touch exploit`' - }).then(function () { - const stat = shell.test('-f', './exploit') - stat.should.equal(false) - }) - }) -}) diff --git a/test/config-files.spec.js b/test/config-files.spec.js new file mode 100644 index 000000000..68f718b7d --- /dev/null +++ b/test/config-files.spec.js @@ -0,0 +1,174 @@ +/* global describe it beforeEach afterEach */ + +'use strict' + +const shell = require('shelljs') +const fs = require('fs') +const { Readable } = require('stream') +const mockery = require('mockery') +const stdMocks = require('std-mocks') + +require('chai').should() + +function exec () { + const cli = require('../command') + const opt = cli.parse('standard-version') + opt.skip = { commit: true, tag: true } + return require('../index')(opt) +} + +/** + * Mock external conventional-changelog modules + * + * Mocks should be unregistered in test cleanup by calling unmock() + * + * bump?: 'major' | 'minor' | 'patch' | Error | (opt, cb) => { cb(err) | cb(null, { releaseType }) } + * changelog?: string | Error | Array string | null> + * tags?: string[] | Error + */ +function mock ({ bump, changelog, tags } = {}) { + mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) + + mockery.registerMock('conventional-recommended-bump', function (opt, cb) { + if (typeof bump === 'function') bump(opt, cb) + else if (bump instanceof Error) cb(bump) + else cb(null, bump ? { releaseType: bump } : {}) + }) + + if (!Array.isArray(changelog)) changelog = [changelog] + mockery.registerMock( + 'conventional-changelog', + (opt) => + new Readable({ + read (_size) { + const next = changelog.shift() + if (next instanceof Error) { + this.destroy(next) + } else if (typeof next === 'function') { + this.push(next(opt)) + } else { + this.push(next ? Buffer.from(next, 'utf8') : null) + } + } + }) + ) + + mockery.registerMock('git-semver-tags', function (cb) { + if (tags instanceof Error) cb(tags) + else cb(null, tags | []) + }) + + stdMocks.use() + return () => stdMocks.flush() +} + +describe('config files', () => { + beforeEach(function () { + shell.rm('-rf', 'tmp') + shell.config.silent = true + shell.mkdir('tmp') + shell.cd('tmp') + fs.writeFileSync( + 'package.json', + JSON.stringify({ version: '1.0.0' }), + 'utf-8' + ) + }) + + afterEach(function () { + shell.cd('../') + shell.rm('-rf', 'tmp') + + mockery.deregisterAll() + mockery.disable() + stdMocks.restore() + + // push out prints from the Mocha reporter + const { stdout } = stdMocks.flush() + for (const str of stdout) { + if (str.startsWith(' ')) process.stdout.write(str) + } + }) + + it('reads config from package.json', async function () { + const issueUrlFormat = 'https://standard-version.company.net/browse/{{id}}' + mock({ + bump: 'minor', + changelog: ({ preset }) => preset.issueUrlFormat + }) + const pkg = { + version: '1.0.0', + repository: { url: 'git+https://company@scm.org/office/app.git' }, + 'standard-version': { issueUrlFormat } + } + fs.writeFileSync('package.json', JSON.stringify(pkg), 'utf-8') + + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(issueUrlFormat) + }) + + it('reads config from .versionrc', async function () { + const issueUrlFormat = 'http://www.foo.com/{{id}}' + const changelog = ({ preset }) => preset.issueUrlFormat + mock({ bump: 'minor', changelog }) + fs.writeFileSync('.versionrc', JSON.stringify({ issueUrlFormat }), 'utf-8') + + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(issueUrlFormat) + }) + + it('reads config from .versionrc.json', async function () { + const issueUrlFormat = 'http://www.foo.com/{{id}}' + const changelog = ({ preset }) => preset.issueUrlFormat + mock({ bump: 'minor', changelog }) + fs.writeFileSync( + '.versionrc.json', + JSON.stringify({ issueUrlFormat }), + 'utf-8' + ) + + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(issueUrlFormat) + }) + + it('evaluates a config-function from .versionrc.js', async function () { + const issueUrlFormat = 'http://www.foo.com/{{id}}' + const src = `module.exports = function() { return ${JSON.stringify({ + issueUrlFormat + })} }` + const changelog = ({ preset }) => preset.issueUrlFormat + mock({ bump: 'minor', changelog }) + fs.writeFileSync('.versionrc.js', src, 'utf-8') + + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(issueUrlFormat) + }) + + it('evaluates a config-object from .versionrc.js', async function () { + const issueUrlFormat = 'http://www.foo.com/{{id}}' + const src = `module.exports = ${JSON.stringify({ issueUrlFormat })}` + const changelog = ({ preset }) => preset.issueUrlFormat + mock({ bump: 'minor', changelog }) + fs.writeFileSync('.versionrc.js', src, 'utf-8') + + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(issueUrlFormat) + }) + + it('throws an error when a non-object is returned from .versionrc.js', async function () { + mock({ bump: 'minor' }) + fs.writeFileSync('.versionrc.js', 'module.exports = 3', 'utf-8') + try { + await exec() + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/Invalid configuration/) + } + }) +}) diff --git a/test/core.spec.js b/test/core.spec.js new file mode 100644 index 000000000..99cb27eb2 --- /dev/null +++ b/test/core.spec.js @@ -0,0 +1,771 @@ +/* global describe it afterEach */ + +'use strict' + +const shell = require('shelljs') +const fs = require('fs') +const { resolve } = require('path') +const { Readable } = require('stream') +const mockFS = require('mock-fs') +const mockery = require('mockery') +const stdMocks = require('std-mocks') + +const cli = require('../command') +const formatCommitMessage = require('../lib/format-commit-message') + +require('chai').should() + +// set by mock() +let standardVersion + +function exec (opt = '', git) { + if (typeof opt === 'string') { + opt = cli.parse(`standard-version ${opt}`) + } + if (!git) opt.skip = Object.assign({}, opt.skip, { commit: true, tag: true }) + return standardVersion(opt) +} + +function getPackageVersion () { + return JSON.parse(fs.readFileSync('package.json', 'utf-8')).version +} + +/** + * Mock external conventional-changelog modules + * + * Mocks should be unregistered in test cleanup by calling unmock() + * + * bump?: 'major' | 'minor' | 'patch' | Error | (opt, cb) => { cb(err) | cb(null, { releaseType }) } + * changelog?: string | Error | Array string | null> + * execFile?: ({ dryRun, silent }, cmd, cmdArgs) => Promise + * fs?: { [string]: string | Buffer | any } + * pkg?: { [string]: any } + * tags?: string[] | Error + */ +function mock ({ bump, changelog, execFile, fs, pkg, tags } = {}) { + mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) + + mockery.registerMock('conventional-recommended-bump', function (opt, cb) { + if (typeof bump === 'function') bump(opt, cb) + else if (bump instanceof Error) cb(bump) + else cb(null, bump ? { releaseType: bump } : {}) + }) + + if (!Array.isArray(changelog)) changelog = [changelog] + mockery.registerMock( + 'conventional-changelog', + (opt) => + new Readable({ + read (_size) { + const next = changelog.shift() + if (next instanceof Error) { + this.destroy(next) + } else if (typeof next === 'function') { + this.push(next(opt)) + } else { + this.push(next ? Buffer.from(next, 'utf8') : null) + } + } + }) + ) + + mockery.registerMock('git-semver-tags', function (cb) { + if (tags instanceof Error) cb(tags) + else cb(null, tags | []) + }) + + if (typeof execFile === 'function') { + // called from commit & tag lifecycle methods + mockery.registerMock('../run-execFile', execFile) + } + + // needs to be set after mockery, but before mock-fs + standardVersion = require('../index') + + fs = Object.assign({}, fs) + if (pkg) { + fs['package.json'] = JSON.stringify(pkg) + } else if (pkg === undefined && !fs['package.json']) { + fs['package.json'] = JSON.stringify({ version: '1.0.0' }) + } + mockFS(fs) + + stdMocks.use() + return () => stdMocks.flush() +} + +function unmock () { + mockery.deregisterAll() + mockery.disable() + mockFS.restore() + stdMocks.restore() + standardVersion = null + + // push out prints from the Mocha reporter + const { stdout } = stdMocks.flush() + for (const str of stdout) { + if (str.startsWith(' ')) process.stdout.write(str) + } +} + +describe('format-commit-message', function () { + it('works for no {{currentTag}}', function () { + formatCommitMessage('chore(release): 1.0.0', '1.0.0').should.equal( + 'chore(release): 1.0.0' + ) + }) + it('works for one {{currentTag}}', function () { + formatCommitMessage('chore(release): {{currentTag}}', '1.0.0').should.equal( + 'chore(release): 1.0.0' + ) + }) + it('works for two {{currentTag}}', function () { + formatCommitMessage( + 'chore(release): {{currentTag}} \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v{{currentTag}}/CHANGELOG.md', + '1.0.0' + ).should.equal( + 'chore(release): 1.0.0 \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v1.0.0/CHANGELOG.md' + ) + }) +}) + +describe('cli', function () { + afterEach(unmock) + + describe('CHANGELOG.md does not exist', function () { + it('populates changelog with commits since last tag by default', async function () { + mock({ bump: 'patch', changelog: 'patch release\n', tags: ['v1.0.0'] }) + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/patch release/) + }) + + it('includes all commits if --first-release is true', async function () { + mock({ + bump: 'minor', + changelog: 'first commit\npatch release\n', + pkg: { version: '1.0.1' } + }) + await exec('--first-release') + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/patch release/) + content.should.match(/first commit/) + }) + + it('skipping changelog will not create a changelog file', async function () { + mock({ bump: 'minor', changelog: 'foo\n' }) + await exec('--skip.changelog true') + getPackageVersion().should.equal('1.1.0') + try { + fs.readFileSync('CHANGELOG.md', 'utf-8') + throw new Error('File should not exist') + } catch (err) { + err.code.should.equal('ENOENT') + } + }) + }) + + describe('CHANGELOG.md exists', function () { + it('appends the new release above the last release, removing the old header (legacy format)', async function () { + mock({ + bump: 'patch', + changelog: 'release 1.0.1\n', + fs: { 'CHANGELOG.md': 'legacy header format\n' }, + tags: ['v1.0.0'] + }) + await exec() + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/1\.0\.1/) + content.should.not.match(/legacy header format/) + }) + + it('appends the new release above the last release, removing the old header (new format)', async function () { + const { header } = require('../defaults') + const changelog1 = + '### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n' + mock({ bump: 'patch', changelog: changelog1, tags: ['v1.0.0'] }) + await exec() + let content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.equal(header + '\n' + changelog1) + + const changelog2 = + '### [1.0.2](/compare/v1.0.1...v1.0.2) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* another patch release ABCDEFXY\n' + unmock() + mock({ + bump: 'patch', + changelog: changelog2, + fs: { 'CHANGELOG.md': content }, + tags: ['v1.0.0', 'v1.0.1'] + }) + await exec() + content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.equal(header + '\n' + changelog2 + changelog1) + }) + + it('[DEPRECATED] (--changelogHeader) allows for a custom changelog header', async function () { + const header = '# Pork Chop Log' + mock({ + bump: 'minor', + changelog: header + '\n', + fs: { 'CHANGELOG.md': '' } + }) + await exec(`--changelogHeader="${header}"`) + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(new RegExp(header)) + }) + + it('[DEPRECATED] (--changelogHeader) exits with error if changelog header matches last version search regex', async function () { + mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) + try { + await exec('--changelogHeader="## 3.0.2"') + throw new Error('That should not have worked') + } catch (error) { + error.message.should.match(/custom changelog header must not match/) + } + }) + }) + + describe('lifecycle scripts', () => { + describe('prerelease hook', function () { + it('should run the prerelease hook when provided', async function () { + const flush = mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ + scripts: { prerelease: 'node -e "console.error(\'prerelease\' + \' ran\')"' } + }) + const { stderr } = flush() + stderr.join('\n').should.match(/prerelease ran/) + }) + + it('should abort if the hook returns a non-zero exit code', async function () { + mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + try { + await exec({ + scripts: { + prerelease: 'node -e "throw new Error(\'prerelease\' + \' fail\')"' + } + }) + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/prerelease fail/) + } + }) + }) + + describe('prebump hook', function () { + it('should allow prebump hook to return an alternate version #', async function () { + const flush = mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ scripts: { prebump: 'node -e "console.log(Array.of(9, 9, 9).join(\'.\'))"' } }) + const { stdout } = flush() + stdout.join('').should.match(/9\.9\.9/) + }) + }) + + describe('postbump hook', function () { + it('should run the postbump hook when provided', async function () { + const flush = mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ + scripts: { postbump: 'node -e "console.error(\'postbump\' + \' ran\')"' } + }) + const { stderr } = flush() + stderr.join('\n').should.match(/postbump ran/) + }) + + it('should run the postbump and exit with error when postbump fails', async function () { + mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + try { + await exec({ + scripts: { postbump: 'node -e "throw new Error(\'postbump\' + \' fail\')"' } + }) + await exec('--patch') + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/postbump fail/) + } + }) + }) + }) + + describe('manual-release', function () { + describe('release-types', function () { + const regularTypes = ['major', 'minor', 'patch'] + const nextVersion = { major: '2.0.0', minor: '1.1.0', patch: '1.0.1' } + + regularTypes.forEach(function (type) { + it('creates a ' + type + ' release', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + await exec('--release-as ' + type) + getPackageVersion().should.equal(nextVersion[type]) + }) + }) + + // this is for pre-releases + regularTypes.forEach(function (type) { + it('creates a pre' + type + ' release', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + await exec('--release-as ' + type + ' --prerelease ' + type) + getPackageVersion().should.equal(`${nextVersion[type]}-${type}.0`) + }) + }) + }) + + describe('release-as-exact', function () { + it('releases as v100.0.0', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + await exec('--release-as v100.0.0') + getPackageVersion().should.equal('100.0.0') + }) + + it('releases as 200.0.0-amazing', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + await exec('--release-as 200.0.0-amazing') + getPackageVersion().should.equal('200.0.0-amazing') + }) + }) + + it('creates a prerelease with a new minor version after two prerelease patches', async function () { + let releaseType = 'patch' + const bump = (_, cb) => cb(null, { releaseType }) + mock({ + bump, + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec('--release-as patch --prerelease dev') + getPackageVersion().should.equal('1.0.1-dev.0') + + await exec('--prerelease dev') + getPackageVersion().should.equal('1.0.1-dev.1') + + releaseType = 'minor' + await exec('--release-as minor --prerelease dev') + getPackageVersion().should.equal('1.1.0-dev.0') + + await exec('--release-as minor --prerelease dev') + getPackageVersion().should.equal('1.1.0-dev.1') + + await exec('--prerelease dev') + getPackageVersion().should.equal('1.1.0-dev.2') + }) + }) + + it('appends line feed at end of package.json', async function () { + mock({ bump: 'patch' }) + await exec() + const pkgJson = fs.readFileSync('package.json', 'utf-8') + pkgJson.should.equal('{\n "version": "1.0.1"\n}\n') + }) + + it('preserves indentation of tabs in package.json', async function () { + mock({ + bump: 'patch', + fs: { 'package.json': '{\n\t"version": "1.0.0"\n}\n' } + }) + await exec() + const pkgJson = fs.readFileSync('package.json', 'utf-8') + pkgJson.should.equal('{\n\t"version": "1.0.1"\n}\n') + }) + + it('preserves indentation of spaces in package.json', async function () { + mock({ + bump: 'patch', + fs: { 'package.json': '{\n "version": "1.0.0"\n}\n' } + }) + await exec() + const pkgJson = fs.readFileSync('package.json', 'utf-8') + pkgJson.should.equal('{\n "version": "1.0.1"\n}\n') + }) + + it('preserves carriage return + line feed in package.json', async function () { + mock({ + bump: 'patch', + fs: { 'package.json': '{\r\n "version": "1.0.0"\r\n}\r\n' } + }) + await exec() + const pkgJson = fs.readFileSync('package.json', 'utf-8') + pkgJson.should.equal('{\r\n "version": "1.0.1"\r\n}\r\n') + }) + + it('does not print output when the --silent flag is passed', async function () { + const flush = mock() + await exec('--silent') + flush().should.eql({ stdout: [], stderr: [] }) + }) +}) + +describe('standard-version', function () { + afterEach(unmock) + + it('should exit on bump error', async function () { + mock({ bump: new Error('bump err') }) + try { + await exec() + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (err) { + err.message.should.match(/bump err/) + } + }) + + it('should exit on changelog error', async function () { + mock({ bump: 'minor', changelog: new Error('changelog err') }) + try { + await exec() + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (err) { + err.message.should.match(/changelog err/) + } + }) + + it('should exit with error without a package file to bump', async function () { + mock({ bump: 'patch', pkg: false }) + try { + await exec({ gitTagFallback: false }) + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (err) { + err.message.should.equal('no package file found') + } + }) + + it('bumps version # in bower.json', async function () { + mock({ + bump: 'minor', + fs: { 'bower.json': JSON.stringify({ version: '1.0.0' }) }, + tags: ['v1.0.0'] + }) + await exec() + JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal( + '1.1.0' + ) + getPackageVersion().should.equal('1.1.0') + }) + + it('bumps version # in manifest.json', async function () { + mock({ + bump: 'minor', + fs: { 'manifest.json': JSON.stringify({ version: '1.0.0' }) }, + tags: ['v1.0.0'] + }) + await exec() + JSON.parse(fs.readFileSync('manifest.json', 'utf-8')).version.should.equal( + '1.1.0' + ) + getPackageVersion().should.equal('1.1.0') + }) + + describe('custom `bumpFiles` support', function () { + it('mix.exs + version.txt', async function () { + const updater = 'custom-updater.js' + const updaterModule = require('./mocks/updater/customer-updater') + mock({ + bump: 'minor', + fs: { + 'mix.exs': fs.readFileSync('./test/mocks/mix.exs'), + 'version.txt': fs.readFileSync('./test/mocks/version.txt') + }, + tags: ['v1.0.0'] + }) + mockery.registerMock(resolve(process.cwd(), updater), updaterModule) + + await exec({ + bumpFiles: [ + 'version.txt', + { filename: 'mix.exs', updater: 'custom-updater.js' } + ] + }) + fs.readFileSync('mix.exs', 'utf-8').should.contain('version: "1.1.0"') + fs.readFileSync('version.txt', 'utf-8').should.equal('1.1.0') + }) + + it('bumps a custom `plain-text` file', async function () { + mock({ + bump: 'minor', + fs: { + 'VERSION_TRACKER.txt': fs.readFileSync( + './test/mocks/VERSION-1.0.0.txt' + ) + } + }) + await exec({ + bumpFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }] + }) + fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('1.1.0') + }) + }) + + describe('custom `packageFiles` support', function () { + it('reads and writes to a custom `plain-text` file', async function () { + mock({ + bump: 'minor', + fs: { + 'VERSION_TRACKER.txt': fs.readFileSync( + './test/mocks/VERSION-6.3.1.txt' + ) + } + }) + await exec({ + packageFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }], + bumpFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }] + }) + fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('6.4.0') + }) + }) + + it('bumps version # in npm-shrinkwrap.json', async function () { + mock({ + bump: 'minor', + fs: { + 'npm-shrinkwrap.json': JSON.stringify({ version: '1.0.0' }) + }, + tags: ['v1.0.0'] + }) + await exec() + JSON.parse( + fs.readFileSync('npm-shrinkwrap.json', 'utf-8') + ).version.should.equal('1.1.0') + getPackageVersion().should.equal('1.1.0') + }) + + it('bumps version # in package-lock.json', async function () { + mock({ + bump: 'minor', + fs: { + '.gitignore': '', + 'package-lock.json': JSON.stringify({ version: '1.0.0' }) + }, + tags: ['v1.0.0'] + }) + await exec() + JSON.parse( + fs.readFileSync('package-lock.json', 'utf-8') + ).version.should.equal('1.1.0') + getPackageVersion().should.equal('1.1.0') + }) + + describe('skip', () => { + it('allows bump and changelog generation to be skipped', async function () { + const changelogContent = 'legacy header format\n' + mock({ + bump: 'minor', + changelog: 'foo\n', + fs: { 'CHANGELOG.md': changelogContent } + }) + + await exec('--skip.bump true --skip.changelog true') + getPackageVersion().should.equal('1.0.0') + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.equal(changelogContent) + }) + }) + + it('does not update files present in .gitignore', async () => { + mock({ + bump: 'minor', + fs: { + '.gitignore': 'bower.json', + 'bower.json': JSON.stringify({ version: '1.0.0' }) + }, + tags: ['v1.0.0'] + }) + await exec() + JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal( + '1.0.0' + ) + getPackageVersion().should.equal('1.1.0') + }) + + describe('configuration', () => { + it('--header', async function () { + mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) + await exec('--header="# Welcome to our CHANGELOG.md"') + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/# Welcome to our CHANGELOG.md/) + }) + + it('--issuePrefixes and --issueUrlFormat', async function () { + const format = 'http://www.foo.com/{{prefix}}{{id}}' + const prefix = 'ABC-' + const changelog = ({ preset }) => + preset.issueUrlFormat + ':' + preset.issuePrefixes + mock({ bump: 'minor', changelog }) + await exec(`--issuePrefixes="${prefix}" --issueUrlFormat="${format}"`) + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.include(`${format}:${prefix}`) + }) + }) + + describe('pre-major', () => { + it('bumps the minor rather than major, if version < 1.0.0', async function () { + mock({ + bump: 'minor', + pkg: { + version: '0.5.0', + repository: { url: 'https://github.com/yargs/yargs.git' } + } + }) + await exec() + getPackageVersion().should.equal('0.6.0') + }) + + it('bumps major if --release-as=major specified, if version < 1.0.0', async function () { + mock({ + bump: 'major', + pkg: { + version: '0.5.0', + repository: { url: 'https://github.com/yargs/yargs.git' } + } + }) + await exec('-r major') + getPackageVersion().should.equal('1.0.0') + }) + }) +}) + +describe('GHSL-2020-111', function () { + afterEach(unmock) + + it('does not allow command injection via basic configuration', async function () { + mock({ bump: 'patch' }) + await exec({ + noVerify: true, + infile: 'foo.txt', + releaseCommitMessageFormat: 'bla `touch exploit`' + }) + const stat = shell.test('-f', './exploit') + stat.should.equal(false) + }) +}) + +describe('with mocked git', function () { + afterEach(unmock) + + it('--sign signs the commit and tag', async function () { + const gitArgs = [ + ['add', 'CHANGELOG.md', 'package.json'], + ['commit', '-S', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'], + ['tag', '-s', 'v1.0.1', '-m', 'chore(release): 1.0.1'], + ['rev-parse', '--abbrev-ref', 'HEAD'] + ] + const execFile = (_args, cmd, cmdArgs) => { + cmd.should.equal('git') + const expected = gitArgs.shift() + cmdArgs.should.deep.equal(expected) + if (expected[0] === 'rev-parse') return Promise.resolve('master') + return Promise.resolve('') + } + mock({ bump: 'patch', changelog: 'foo\n', execFile }) + + await exec('--sign', true) + gitArgs.should.have.lengthOf(0) + }) + + it('fails if git add fails', async function () { + const gitArgs = [ + ['add', 'CHANGELOG.md', 'package.json'] + ] + const execFile = (_args, cmd, cmdArgs) => { + cmd.should.equal('git') + const expected = gitArgs.shift() + cmdArgs.should.deep.equal(expected) + if (expected[0] === 'add') { + return Promise.reject(new Error('Command failed: git\nfailed add')) + } + return Promise.resolve('') + } + mock({ bump: 'patch', changelog: 'foo\n', execFile }) + + try { + await exec({}, true) + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/failed add/) + } + }) + + it('fails if git commit fails', async function () { + const gitArgs = [ + ['add', 'CHANGELOG.md', 'package.json'], + ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'] + ] + const execFile = (_args, cmd, cmdArgs) => { + cmd.should.equal('git') + const expected = gitArgs.shift() + cmdArgs.should.deep.equal(expected) + if (expected[0] === 'commit') { + return Promise.reject(new Error('Command failed: git\nfailed commit')) + } + return Promise.resolve('') + } + mock({ bump: 'patch', changelog: 'foo\n', execFile }) + + try { + await exec({}, true) + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/failed commit/) + } + }) + + it('fails if git tag fails', async function () { + const gitArgs = [ + ['add', 'CHANGELOG.md', 'package.json'], + ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'], + ['tag', '-a', 'v1.0.1', '-m', 'chore(release): 1.0.1'] + ] + const execFile = (_args, cmd, cmdArgs) => { + cmd.should.equal('git') + const expected = gitArgs.shift() + cmdArgs.should.deep.equal(expected) + if (expected[0] === 'tag') { + return Promise.reject(new Error('Command failed: git\nfailed tag')) + } + return Promise.resolve('') + } + mock({ bump: 'patch', changelog: 'foo\n', execFile }) + + try { + await exec({}, true) + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/failed tag/) + } + }) +}) diff --git a/test/git.spec.js b/test/git.spec.js new file mode 100644 index 000000000..448937286 --- /dev/null +++ b/test/git.spec.js @@ -0,0 +1,352 @@ +/* global describe it beforeEach afterEach */ + +'use strict' + +const shell = require('shelljs') +const fs = require('fs') +const { Readable } = require('stream') +const mockery = require('mockery') +const stdMocks = require('std-mocks') + +require('chai').should() + +function exec (opt = '') { + if (typeof opt === 'string') { + const cli = require('../command') + opt = cli.parse(`standard-version ${opt}`) + } + return require('../index')(opt) +} + +function writePackageJson (version, option) { + const pkg = Object.assign({}, option, { version }) + fs.writeFileSync('package.json', JSON.stringify(pkg), 'utf-8') +} + +function writeHook (hookName, causeError, script) { + shell.mkdir('-p', 'scripts') + let content = script || 'console.error("' + hookName + ' ran")' + content += causeError ? '\nthrow new Error("' + hookName + '-failure")' : '' + fs.writeFileSync('scripts/' + hookName + '.js', content, 'utf-8') + fs.chmodSync('scripts/' + hookName + '.js', '755') +} + +function getPackageVersion () { + return JSON.parse(fs.readFileSync('package.json', 'utf-8')).version +} + +/** + * Mock external conventional-changelog modules + * + * bump: 'major' | 'minor' | 'patch' | Error | (opt, cb) => { cb(err) | cb(null, { releaseType }) } + * changelog?: string | Error | Array string | null> + * tags?: string[] | Error + */ +function mock ({ bump, changelog, tags }) { + if (bump === undefined) throw new Error('bump must be defined for mock()') + mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) + + mockery.registerMock('conventional-recommended-bump', function (opt, cb) { + if (typeof bump === 'function') bump(opt, cb) + else if (bump instanceof Error) cb(bump) + else cb(null, { releaseType: bump }) + }) + + if (!Array.isArray(changelog)) changelog = [changelog] + mockery.registerMock('conventional-changelog', (opt) => new Readable({ + read (_size) { + const next = changelog.shift() + if (next instanceof Error) { + this.destroy(next) + } else if (typeof next === 'function') { + this.push(next(opt)) + } else { + this.push(next ? Buffer.from(next, 'utf8') : null) + } + } + })) + + mockery.registerMock('git-semver-tags', function (cb) { + if (tags instanceof Error) cb(tags) + else cb(null, tags || []) + }) + + stdMocks.use() + return () => stdMocks.flush() +} + +describe('git', function () { + beforeEach(function () { + shell.rm('-rf', 'tmp') + shell.config.silent = true + shell.mkdir('tmp') + shell.cd('tmp') + shell.exec('git init') + shell.exec('git config commit.gpgSign false') + shell.exec('git config core.autocrlf false') + shell.exec('git commit --allow-empty -m"root-commit"') + writePackageJson('1.0.0') + }) + + afterEach(function () { + shell.cd('../') + shell.rm('-rf', 'tmp') + + mockery.deregisterAll() + mockery.disable() + stdMocks.restore() + + // push out prints from the Mocha reporter + const { stdout } = stdMocks.flush() + for (const str of stdout) { + if (str.startsWith(' ')) process.stdout.write(str) + } + }) + + it('formats the commit and tag messages appropriately', async function () { + mock({ bump: 'minor', tags: ['v1.0.0'] }) + await exec({}) + // check last commit message + shell.exec('git log --oneline -n1').stdout.should.match(/chore\(release\): 1\.1\.0/) + // check annotated tag message + shell.exec('git tag -l -n1 v1.1.0').stdout.should.match(/chore\(release\): 1\.1\.0/) + }) + + it('formats the tag if --first-release is true', async function () { + writePackageJson('1.0.1') + mock({ bump: 'minor' }) + await exec('--first-release') + shell.exec('git tag').stdout.should.match(/1\.0\.1/) + }) + + it('commits all staged files', async function () { + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + fs.writeFileSync('STUFF.md', 'stuff\n', 'utf-8') + shell.exec('git add STUFF.md') + + mock({ bump: 'patch', changelog: 'release 1.0.1\n', tags: ['v1.0.0'] }) + await exec('--commit-all') + const status = shell.exec('git status --porcelain') // see http://unix.stackexchange.com/questions/155046/determine-if-git-working-directory-is-clean-from-a-script + status.should.equal('') + status.should.not.match(/STUFF.md/) + + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/1\.0\.1/) + content.should.not.match(/legacy header format/) + }) + + it('does not run git hooks if the --no-verify flag is passed', async function () { + fs.writeFileSync('.git/hooks/pre-commit', '#!/bin/sh\necho "precommit ran"\nexit 1', 'utf-8') + fs.chmodSync('.git/hooks/pre-commit', '755') + + mock({ bump: 'minor' }) + await exec('--no-verify') + await exec('-n') + }) + + it('allows the commit phase to be skipped', async function () { + const changelogContent = 'legacy header format\n' + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', changelogContent, 'utf-8') + + mock({ bump: 'minor', changelog: 'new feature\n' }) + await exec('--skip.commit true') + getPackageVersion().should.equal('1.1.0') + const content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/new feature/) + shell.exec('git log --oneline -n1').stdout.should.match(/root-commit/) + }) + + it('dry-run skips all non-idempotent steps', async function () { + shell.exec('git tag -a v1.0.0 -m "my awesome first release"') + const flush = mock({ bump: 'minor', changelog: '### Features\n', tags: ['v1.0.0'] }) + await exec('--dry-run') + const { stdout } = flush() + stdout.join('').should.match(/### Features/) + shell.exec('git log --oneline -n1').stdout.should.match(/root-commit/) + shell.exec('git tag').stdout.should.match(/1\.0\.0/) + getPackageVersion().should.equal('1.0.0') + }) + + it('works fine without specifying a tag id when prereleasing', async function () { + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + mock({ bump: 'minor' }) + await exec('--prerelease') + getPackageVersion().should.equal('1.1.0-0') + }) + + describe('gitTagFallback', () => { + it('defaults to 1.0.0 if no tags in git history', async () => { + shell.rm('package.json') + mock({ bump: 'minor' }) + await exec({}) + const output = shell.exec('git tag') + output.stdout.should.include('v1.1.0') + }) + + it('bases version on greatest version tag, if tags are found', async () => { + shell.rm('package.json') + mock({ bump: 'minor', tags: ['v3.9.0', 'v5.0.0', 'v3.0.0'] }) + await exec({}) + const output = shell.exec('git tag') + output.stdout.should.include('v5.1.0') + }) + }) + + describe('configuration', () => { + it('.versionrc : releaseCommitMessageFormat', async function () { + fs.writeFileSync('.versionrc', JSON.stringify({ + releaseCommitMessageFormat: 'This commit represents release: {{currentTag}}' + }), 'utf-8') + mock({ bump: 'minor' }) + await exec('') + shell.exec('git log --oneline -n1').should.include('This commit represents release: 1.1.0') + }) + + it('--releaseCommitMessageFormat', async function () { + mock({ bump: 'minor' }) + await exec('--releaseCommitMessageFormat="{{currentTag}} is the version."') + shell.exec('git log --oneline -n1').should.include('1.1.0 is the version.') + }) + + it('[LEGACY] supports --message (and single %s replacement)', async function () { + mock({ bump: 'minor' }) + await exec('--message="V:%s"') + shell.exec('git log --oneline -n1').should.include('V:1.1.0') + }) + + it('[LEGACY] supports -m (and multiple %s replacements)', async function () { + mock({ bump: 'minor' }) + await exec('--message="V:%s is the %s."') + shell.exec('git log --oneline -n1').should.include('V:1.1.0 is the 1.1.0.') + }) + }) + + describe('precommit hook', function () { + it('should run the precommit hook when provided via .versionrc.json (#371)', async function () { + fs.writeFileSync('.versionrc.json', JSON.stringify({ + scripts: { precommit: 'node scripts/precommit' } + }), 'utf-8') + + writeHook('precommit') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + const flush = mock({ bump: 'minor' }) + await exec('') + const { stderr } = flush() + stderr[0].should.match(/precommit ran/) + }) + + it('should run the precommit hook when provided', async function () { + writePackageJson('1.0.0', { + 'standard-version': { + scripts: { precommit: 'node scripts/precommit' } + } + }) + writeHook('precommit') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + const flush = mock({ bump: 'minor' }) + await exec('--patch') + const { stderr } = flush() + stderr[0].should.match(/precommit ran/) + }) + + it('should run the precommit hook and exit with error when precommit fails', async function () { + writePackageJson('1.0.0', { + 'standard-version': { + scripts: { precommit: 'node scripts/precommit' } + } + }) + writeHook('precommit', true) + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + mock({ bump: 'minor' }) + try { + await exec('--patch') + /* istanbul ignore next */ + throw new Error('Unexpected success') + } catch (error) { + error.message.should.match(/precommit-failure/) + } + }) + + it('should allow an alternate commit message to be provided by precommit script', async function () { + writePackageJson('1.0.0', { + 'standard-version': { + scripts: { precommit: 'node scripts/precommit' } + } + }) + writeHook('precommit', false, 'console.log("releasing %s delivers #222")') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + mock({ bump: 'minor' }) + await exec('--patch') + shell.exec('git log --oneline -n1').should.match(/delivers #222/) + }) + }) + + describe('Run ... to publish', function () { + it('does normally display `npm publish`', async function () { + const flush = mock({ bump: 'patch' }) + await exec('') + flush().stdout.join('').should.match(/npm publish/) + }) + + it('does not display `npm publish` if the package is private', async function () { + writePackageJson('1.0.0', { private: true }) + const flush = mock({ bump: 'patch' }) + await exec('') + flush().stdout.join('').should.not.match(/npm publish/) + }) + + it('does not display `npm publish` if there is no package.json', async function () { + shell.rm('package.json') + const flush = mock({ bump: 'patch' }) + await exec('') + flush().stdout.join('').should.not.match(/npm publish/) + }) + + it('does not display `all staged files` without the --commit-all flag', async function () { + const flush = mock({ bump: 'patch' }) + await exec('') + flush().stdout.join('').should.not.match(/all staged files/) + }) + + it('does display `all staged files` if the --commit-all flag is passed', async function () { + const flush = mock({ bump: 'patch' }) + await exec('--commit-all') + flush().stdout.join('').should.match(/all staged files/) + }) + + it('advises use of --tag prerelease for publishing to npm', async function () { + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + const flush = mock({ bump: 'patch' }) + await exec('--prerelease') + const { stdout } = flush() + stdout.join('').should.include('--tag prerelease') + }) + + it('advises use of --tag alpha for publishing to npm when tagging alpha', async function () { + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + const flush = mock({ bump: 'patch' }) + await exec('--prerelease alpha') + const { stdout } = flush() + stdout.join('').should.include('--tag alpha') + }) + + it('does not advise use of --tag prerelease for private modules', async function () { + writePackageJson('1.0.0', { private: true }) + fs.writeFileSync('CHANGELOG.md', 'legacy header format\n', 'utf-8') + + const flush = mock({ bump: 'minor' }) + await exec('--prerelease') + const { stdout } = flush() + stdout.join('').should.not.include('--tag prerelease') + }) + }) +})