UNPKG

commit-and-tag-version

Version:

replacement for `npm version` with automatic CHANGELOG generation

1,650 lines (1,434 loc) 59.8 kB
'use strict'; const shell = require('shelljs'); const stripAnsi = require('strip-ansi'); const fs = require('fs'); const mockers = require('./mocks/jest-mocks'); const runExecFile = require('../lib/run-execFile'); const cli = require('../command'); const formatCommitMessage = require('../lib/format-commit-message'); // set by mock() let standardVersion; let readFileSyncSpy; let lstatSyncSpy; // Rather than trying to re-read something written out during tests, we can spy on writeFileSync // we can trust fs is capable of writing the file let writeFileSyncSpy; const consoleErrorSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); jest.mock('../lib/run-execFile'); const { readFileSync: readFileSyncActual, lstatSync: lstatSyncActual } = fs; function exec(opt = '', git) { if (typeof opt === 'string') { opt = cli.parse(`commit-and-tag-version ${opt}`); } if (!git) opt.skip = Object.assign({}, opt.skip, { commit: true, tag: true }); return standardVersion(opt); } function attemptingToReadPackageJson(path) { return path.includes('package.json') || path.includes('package-lock.json'); } /** * @param fs - reference to 'fs' - needs to be defined in the root test class so that mocking works correctly * @param readFileSyncActual - actual implementation of fs.readFileSync - reference should be defined as a variable in root test class so we can unset spy after * @param existingChangelog ?: string - Existing CHANGELOG.md content * @param testFiles ?: object[] - with Path and Value fields, for mocking readFileSynch on packageFiles such as package.json, bower.json, manifest.json * @param realTestFiles ?: object[] - with Filename (e.g. mix.exs) and Path to real file in a directory * @return Jest spy on readFileSync */ const mockReadFilesFromDisk = ({ fs, readFileSyncActual, existingChangelog, testFiles, realTestFiles, }) => jest.spyOn(fs, 'readFileSync').mockImplementation((path, opts) => { if (path === 'CHANGELOG.md') { if (existingChangelog) { return existingChangelog; } return ''; } // If deliberately set to null when mocking, don't create a fake package.json if (testFiles === null && attemptingToReadPackageJson(path)) { return '{}'; } if (testFiles) { const file = testFiles.find((otherFile) => { return path.includes(otherFile.path); }); if (file) { if (file.value instanceof String || typeof file.value === 'string') { return file.value; } return JSON.stringify(file.value); } // For scenarios where we have defined testFiles such as bower.json // Do not create a fake package.json file if (attemptingToReadPackageJson(path)) { return '{}'; } } // If no package files defined and not explicitly set to null, create a fake package json // otherwise fs will read the real package.json in the root of this project! if (attemptingToReadPackageJson(path)) { return JSON.stringify({ version: '1.0.0' }); } if (realTestFiles) { const testFile = realTestFiles.find((testFile) => { return path.includes(testFile.filename); }); if (testFile) { return readFileSyncActual(testFile.path, opts); } } return readFileSyncActual(path, opts); }); /** * @param fs - reference to 'fs' - needs to be defined in the root test class so that mocking works correctly * @param lstatSyncActual - actual implementation of fs.lstatSync * @param testFiles ?: object[] - with Path and Value fields, for mocking lstatSync on packageFiles such as package.json, bower.json, manifest.json * @param realTestFiles ?: object[] - with Filename (e.g. mix.exs) and Path to real file in a directory * @return Jest spy on lstatSync */ const mockFsLStat = ({ fs, lstatSyncActual, testFiles, realTestFiles }) => jest.spyOn(fs, 'lstatSync').mockImplementation((path) => { if (testFiles) { const file = testFiles.find((otherFile) => { return path.includes(otherFile.path); }); if (file) { return { isFile: () => true, }; } } if (realTestFiles) { const file = realTestFiles.find((otherFile) => { return path.includes(otherFile.filename); }); if (file) { return { isFile: () => true, }; } } return lstatSyncActual(path); }); /** * Mock external conventional-changelog modules * * Mocks should be unregistered in test cleanup by calling unmock() * * bump?: 'major' | 'minor' | 'patch' | Error | (opt, parserOpts, cb) => { cb(err) | cb(null, { releaseType }) } * changelog?: string | Error | Array<string | Error | (opt) => string | null> - Changelog to be "generated" by conventional-changelog when reading commit history * execFile?: ({ dryRun, silent }, cmd, cmdArgs) => Promise<string> * tags?: string[] | Error * existingChangelog?: string - Existing CHANGELOG.md content * testFiles?: object[] - with Path and Value fields, for mocking readFileSynch on packageFiles such as package.json, bower.json, manifest.json * realTestFiles?: object[] - with Filename (e.g. mix.exs) and Path to real file in test directory */ function mock({ bump, changelog, tags, existingChangelog, testFiles, realTestFiles, } = {}) { mockers.mockRecommendedBump({ bump }); if (!Array.isArray(changelog)) changelog = [changelog]; mockers.mockConventionalChangelog({ changelog, }); mockers.mockGitSemverTags({ tags, }); // needs to be set after mockery, but before mock-fs standardVersion = require('../index'); // For fake and injected test files pretend they exist at root level when Fs queries lstat // Package.json works without this as it'll check the one in this actual repo... lstatSyncSpy = mockFsLStat({ fs, lstatSyncActual, testFiles, realTestFiles, }); readFileSyncSpy = mockReadFilesFromDisk({ fs, readFileSyncActual, existingChangelog, testFiles, realTestFiles, }); // Spies on writeFileSync to capture calls and ensure we don't actually try write anything to disc writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); } function clearCapturedSpyCalls() { consoleInfoSpy.mockClear(); consoleErrorSpy.mockClear(); } function restoreMocksToRealImplementation() { readFileSyncSpy.mockRestore(); writeFileSyncSpy.mockRestore(); lstatSyncSpy.mockRestore(); } function unmock() { clearCapturedSpyCalls(); restoreMocksToRealImplementation(); standardVersion = null; } describe('format-commit-message', function () { it('works for no {{currentTag}}', function () { expect(formatCommitMessage('chore(release): 1.0.0', '1.0.0')).toEqual( 'chore(release): 1.0.0', ); }); it('works for one {{currentTag}}', function () { expect( formatCommitMessage('chore(release): {{currentTag}}', '1.0.0'), ).toEqual('chore(release): 1.0.0'); }); it('works for two {{currentTag}}', function () { expect( formatCommitMessage( 'chore(release): {{currentTag}} \n\n* CHANGELOG: https://github.com/absolute-version/commit-and-tag-version/blob/v{{currentTag}}/CHANGELOG.md', '1.0.0', ), ).toEqual( 'chore(release): 1.0.0 \n\n* CHANGELOG: https://github.com/absolute-version/commit-and-tag-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(); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: /patch release/, }); }); it('includes all commits if --first-release is true', async function () { mock({ bump: 'minor', changelog: 'first commit\npatch release\n', testFiles: [{ path: 'package.json', value: { version: '1.0.1' } }], }); await exec('--first-release'); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: /patch release/, }); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: /first commit/, }); }); it('skipping changelog will not create a changelog file', async function () { mock({ bump: 'minor', changelog: 'foo\n' }); await exec('--skip.changelog true'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); expect(writeFileSyncSpy).not.toHaveBeenCalledWith('CHANGELOG.md'); }); }); describe('CHANGELOG.md exists', function () { afterEach(unmock); it('appends the new release above the last release, removing the old header (legacy format), and does not retain any front matter', async function () { const frontMatter = '---\nstatus: new\n---\n'; mock({ bump: 'patch', changelog: 'release 1.0.1\n', existingChangelog: frontMatter + 'legacy header format<a name="1.0.0">\n', tags: ['v1.0.0'], }); await exec(); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: /1\.0\.1/, }); verifyNewChangelogContentDoesNotMatch({ writeFileSyncSpy, expectedContent: /legacy header format/, }); verifyNewChangelogContentDoesNotMatch({ writeFileSyncSpy, expectedContent: /---status: new---/, }); }); it('appends the new release above the last release, replacing the old header (standard-version format) with header (new format), and retains any front matter', async function () { const { header } = require('../defaults'); const standardVersionHeader = '# 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.'; const frontMatter = '---\nstatus: new\n---\n'; const changelog101 = '### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n'; const changelog100 = '### [1.0.0](/compare/v0.0.1...v1.0.0) (YYYY-MM-DD)\n\n\n### Features\n\n* Version one feature set\n'; const initialChangelog = frontMatter + '\n' + standardVersionHeader + '\n' + changelog100; mock({ bump: 'patch', changelog: changelog101, existingChangelog: initialChangelog, tags: ['v1.0.0'], }); await exec(); verifyNewChangelogContentEquals({ writeFileSyncSpy, expectedContent: frontMatter + '\n' + header + '\n' + changelog101 + changelog100, }); }); it('appends the new release above the last release, removing the old header (new format), and retains any front matter', async function () { const { header } = require('../defaults'); const frontMatter = '---\nstatus: new\n---\n'; const changelog101 = '### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n'; const changelog100 = '### [1.0.0](/compare/v0.0.1...v1.0.0) (YYYY-MM-DD)\n\n\n### Features\n\n* Version one feature set\n'; const initialChangelog = frontMatter + '\n' + header + '\n' + changelog100; mock({ bump: 'patch', changelog: changelog101, existingChangelog: initialChangelog, tags: ['v1.0.0'], }); await exec(); verifyNewChangelogContentEquals({ writeFileSyncSpy, expectedContent: frontMatter + '\n' + header + '\n' + changelog101 + changelog100, }); }); 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(); const content = header + '\n' + changelog1; verifyNewChangelogContentEquals({ writeFileSyncSpy, expectedContent: content, }); 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, existingChangelog: content, tags: ['v1.0.0', 'v1.0.1'], }); await exec(); verifyNewChangelogContentEquals({ writeFileSyncSpy, expectedContent: 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', existingChangelog: '', }); await exec(`--changelogHeader="${header}"`); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: new RegExp(header), }); }); it('[DEPRECATED] (--changelogHeader) exits with error if changelog header matches last version search regex', async function () { mock({ bump: 'minor', existingChangelog: '' }); await expect(exec('--changelogHeader="## 3.0.2"')).rejects.toThrow( /custom changelog header must not match/, ); }); }); describe('lifecycle scripts', function () { afterEach(unmock); describe('prerelease hook', function () { it('should run the prerelease hook when provided', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { prerelease: "node -e \"console.error('prerelease' + ' ran')\"", }, }); const expectedLog = 'prerelease ran'; verifyLogPrinted({ consoleInfoSpy: consoleErrorSpy, expectedLog }); }); it('should abort if the hook returns a non-zero exit code', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await expect( exec({ scripts: { prerelease: "node -e \"throw new Error('prerelease' + ' fail')\"", }, }), ).rejects.toThrow(/prerelease fail/); }); }); describe('prebump hook', function () { it('should allow prebump hook to return an alternate version #', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { prebump: 'node -e "console.log(Array.of(9, 9, 9).join(\'.\'))"', }, }); verifyLogPrinted({ consoleInfoSpy, expectedLog: '9.9.9' }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '9.9.9' }); }); it('should not allow prebump hook to return a releaseAs command', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { prebump: 'node -e "console.log(\'major\')"', }, }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); it('should allow prebump hook to return an arbitrary string', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { prebump: 'node -e "console.log(\'Hello World\')"', }, }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); it('should allow prebump hook to return a version with build info', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { prebump: 'node -e "console.log(\'9.9.9-test+build\')"', }, }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '9.9.9-test+build', }); }); }); describe('postbump hook', function () { it('should run the postbump hook when provided', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec({ scripts: { postbump: "node -e \"console.error('postbump' + ' ran')\"", }, }); const expectedLog = 'postbump ran'; verifyLogPrinted({ consoleInfoSpy: consoleErrorSpy, expectedLog }); }); it('should run the postbump and exit with error when postbump fails', async function () { mock({ bump: 'minor', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await expect( exec({ scripts: { postbump: "node -e \"throw new Error('postbump' + ' fail')\"", }, }), ).rejects.toThrow(/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', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec('--release-as ' + type); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: nextVersion[type], }); }); }); // this is for pre-releases regularTypes.forEach(function (type) { it('creates a pre' + type + ' release', async function () { mock({ bump: 'patch', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec('--release-as ' + type + ' --prerelease ' + type); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: `${nextVersion[type]}-${type}.0`, }); }); }); it('exits with error if an invalid release type is provided', async function () { mock({ bump: 'minor', existingChangelog: '' }); await expect(exec('--release-as invalid')).rejects.toThrow( /releaseAs must be one of/, ); }); }); describe('release-as-exact', function () { it('releases as v100.0.0', async function () { mock({ bump: 'patch', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec('--release-as v100.0.0'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0', }); }); it('releases as 200.0.0-amazing', async function () { mock({ bump: 'patch', existingChangelog: 'legacy header format<a name="1.0.0">\n', }); await exec('--release-as 200.0.0-amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '200.0.0-amazing', }); }); it('releases as 100.0.0 with prerelease amazing', async function () { mock({ bump: 'patch', existingChangelog: 'legacy header format<a name="1.0.0">\n', testFiles: [ { path: 'package.json', value: { version: '1.0.0', }, }, ], }); await exec('--release-as 100.0.0 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.0', }); }); it('release 100.0.0 with prerelease amazing bumps build', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '100.0.0-amazing.0', }, }, ], }); await exec('--release-as 100.0.0 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.1', }); }); it('release 100.0.0-amazing.0 with prerelease amazing bumps build', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '100.0.0-amazing.1', }, }, ], }); await exec('--release-as 100.0.0-amazing.0 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.2', }); }); it('release 100.0.0 with prerelease amazing correctly sets version', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '99.0.0-amazing.0', }, }, ], }); await exec('--release-as 100.0.0 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.0', }); }); it('release 100.0.0-amazing.0 with prerelease amazing correctly sets version', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '99.0.0-amazing.0', }, }, ], }); await exec('--release-as 100.0.0-amazing.0 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.0', }); }); it('release 100.0.0-amazing.0 with prerelease amazing retains build metadata', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '100.0.0-amazing.0', }, }, ], }); await exec( '--release-as 100.0.0-amazing.0+build.1234 --prerelease amazing', ); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.1+build.1234', }); }); it('release 100.0.0-amazing.3 with prerelease amazing correctly sets prerelease version', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="100.0.0-amazing.0">\n', }, testFiles: [ { path: 'package.json', value: { version: '100.0.0-amazing.0', }, }, ], }); await exec('--release-as 100.0.0-amazing.3 --prerelease amazing'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '100.0.0-amazing.3', }); }); }); it('creates a prerelease with a new minor version after two prerelease patches', async function () { let releaseType = 'patch'; mock({ bump: (_, __, cb) => cb(null, { releaseType }), existingChangelog: 'legacy header format<a name="1.0.0">\n', }); let version = '1.0.1-dev.0'; await exec('--release-as patch --prerelease dev'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: version }); unmock(); mock({ bump: (_, __, cb) => cb(null, { releaseType }), existingChangelog: 'legacy header format<a name="1.0.0">\n', testFiles: [{ path: 'package.json', value: { version } }], }); version = '1.0.1-dev.1'; await exec('--prerelease dev'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: version }); releaseType = 'minor'; unmock(); mock({ bump: (_, __, cb) => cb(null, { releaseType }), existingChangelog: 'legacy header format<a name="1.0.0">\n', testFiles: [{ path: 'package.json', value: { version } }], }); version = '1.1.0-dev.0'; await exec('--release-as minor --prerelease dev'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: version }); unmock(); mock({ bump: (_, __, cb) => cb(null, { releaseType }), existingChangelog: 'legacy header format<a name="1.0.0">\n', testFiles: [{ path: 'package.json', value: { version } }], }); version = '1.1.0-dev.1'; await exec('--release-as minor --prerelease dev'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: version }); unmock(); mock({ bump: (_, __, cb) => cb(null, { releaseType }), existingChangelog: 'legacy header format<a name="1.0.0">\n', testFiles: [{ path: 'package.json', value: { version } }], }); version = '1.1.0-dev.2'; await exec('--prerelease dev'); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: version }); }); it('exits with error if an invalid release version is provided', async function () { mock({ bump: 'minor', existingChangelog: '' }); await expect(exec('--release-as 10.2')).rejects.toThrow( /releaseAs must be one of/, ); }); it('exits with error if release version conflicts with prerelease', async function () { mock({ bump: 'minor', existingChangelog: '' }); await expect( exec('--release-as 1.2.3-amazing.2 --prerelease awesome'), ).rejects.toThrow( /releaseAs and prerelease have conflicting prerelease identifiers/, ); }); }); it('appends line feed at end of package.json', async function () { mock({ bump: 'patch' }); await exec(); verifyFileContentEquals({ writeFileSyncSpy, content: '{\n "version": "1.0.1"\n}\n', }); }); it('preserves indentation of tabs in package.json', async function () { mock({ bump: 'patch', testFiles: [ { path: 'package.json', value: '{\n\t"version": "1.0.0"\n}\n' }, ], }); await exec(); // TODO: a) not bumping to 1.0.1, b) need to check how jest might handle tabbing etc verifyFileContentEquals({ writeFileSyncSpy, content: '{\n\t"version": "1.0.1"\n}\n', }); }); it('preserves indentation of spaces in package.json', async function () { mock({ bump: 'patch', testFiles: [ { path: 'package.json', value: '{\n "version": "1.0.0"\n}\n' }, ], }); await exec(); verifyFileContentEquals({ writeFileSyncSpy, content: '{\n "version": "1.0.1"\n}\n', }); }); it('preserves carriage return + line feed in package.json', async function () { mock({ bump: 'patch', testFiles: [ { path: 'package.json', value: '{\r\n "version": "1.0.0"\r\n}\r\n' }, ], }); await exec(); verifyFileContentEquals({ writeFileSyncSpy, content: '{\r\n "version": "1.0.1"\r\n}\r\n', }); }); it('does not print output when the --silent flag is passed', async function () { mock(); await exec('--silent'); expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleInfoSpy).not.toHaveBeenCalled(); }); }); describe('commit-and-tag-version', function () { afterEach(unmock); it('should exit on bump error', async function () { mock({ bump: new Error('bump err') }); await expect(exec()).rejects.toThrow(/bump err/); }); it('should exit on changelog error', async function () { mock({ bump: 'minor', changelog: new Error('changelog err') }); await expect(exec()).rejects.toThrow(/changelog err/); }); it('should exit with error without a package file to bump', async function () { mock({ bump: 'patch', testFiles: null }); await expect(exec({ gitTagFallback: false })).rejects.toThrow( 'no package file found', ); }); it('bumps version # in bower.json', async function () { mock({ bump: 'minor', testFiles: [{ path: 'bower.json', value: { version: '1.0.0' } }], tags: ['v1.0.0'], }); await exec(); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'bower.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); it('bumps version # in manifest.json', async function () { mock({ bump: 'minor', testFiles: [{ path: 'manifest.json', value: { version: '1.0.0' } }], tags: ['v1.0.0'], }); await exec(); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'manifest.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); describe('custom `bumpFiles` support', function () { afterEach(unmock); it('mix.exs + version.txt', async function () { mock({ bump: 'minor', realTestFiles: [ { filename: 'mix.exs', path: './test/mocks/mix.exs' }, { filename: 'version.txt', path: './test/mocks/version.txt' }, ], tags: ['v1.0.0'], }); await exec({ bumpFiles: [ 'version.txt', { filename: 'mix.exs', updater: './test/mocks/updater/customer-updater', }, ], }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'mix.exs', asString: true, }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'version.txt', asString: true, }); }); it('bumps a custom `plain-text` file', async function () { mock({ bump: 'minor', realTestFiles: [ { filename: 'VERSION_TRACKER.txt', path: './test/mocks/VERSION-1.0.0.txt', }, ], }); await exec({ bumpFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }], }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'VERSION_TRACKER.txt', asString: true, }); }); it('displays the new version from custom bumper with --dry-run', async function () { mock({ bump: 'minor', realTestFiles: [ { filename: 'increment-version.txt', path: './test/mocks/increment-version.txt', }, ], }); const origInfo = console.info; const capturedOutput = []; console.info = (...args) => { capturedOutput.push(...args); origInfo(...args); }; try { await exec({ bumpFiles: [ { filename: 'increment-version.txt', updater: './test/mocks/updater/increment-updater', }, ], dryRun: true, }); const logOutput = capturedOutput.join(' '); expect(stripAnsi(logOutput)).toContain( 'bumping version in increment-version.txt from 1 to 2', ); } finally { console.info = origInfo; } }); }); describe('custom `packageFiles` support', function () { afterEach(unmock); it('reads and writes to a custom `plain-text` file', async function () { mock({ bump: 'minor', realTestFiles: [ { filename: 'VERSION_TRACKER.txt', path: './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' }], }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '6.4.0', filename: 'VERSION_TRACKER.txt', asString: true, }); }); it('allows same object to be used in packageFiles and bumpFiles', async function () { mock({ bump: 'minor', realTestFiles: [ { filename: 'VERSION_TRACKER.txt', path: './test/mocks/VERSION-6.3.1.txt', }, ], }); const origWarn = console.warn; console.warn = () => { throw new Error('console.warn should not be called'); }; const filedesc = { filename: 'VERSION_TRACKER.txt', type: 'plain-text', }; try { await exec({ packageFiles: [filedesc], bumpFiles: [filedesc] }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '6.4.0', filename: 'VERSION_TRACKER.txt', asString: true, }); } finally { console.warn = origWarn; } }); it('bumps version in Python `pyproject.toml` file', async function () { const expected = fs.readFileSync( './test/mocks/pyproject-1.1.0.toml', 'utf-8', ); const filename = 'python.toml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/pyproject-1.0.0.toml', }, ], }); await exec({ packageFiles: [{ filename, type: 'python' }], bumpFiles: [{ filename, type: 'python' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); }); it('`packageFiles` are bumped along with `bumpFiles` defaults [commit-and-tag-version#533]', async function () { mock({ bump: 'minor', testFiles: [ { path: '.gitignore', value: '', }, { path: 'package-lock.json', value: { version: '1.0.0' }, }, ], realTestFiles: [ { filename: 'manifest.json', path: './test/mocks/manifest-6.3.1.json', }, ], tags: ['v1.0.0'], }); await exec({ packageFiles: [ { filename: 'manifest.json', type: 'json', }, ], }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '6.4.0', filename: 'package.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '6.4.0', filename: 'package-lock.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '6.4.0', filename: 'manifest.json', }); }); it('bumps version in OpenAPI `openapi.yaml` file with CRLF Line Endings', async function () { const expected = fs.readFileSync( './test/mocks/openapi-1.3.0-crlf.yaml', 'utf-8', ); const filename = 'openapi.yaml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/openapi-1.2.3-crlf.yaml', }, ], }); await exec({ packageFiles: [{ filename, type: 'openapi' }], bumpFiles: [{ filename, type: 'openapi' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in OpenAPI `openapi.yaml` file with LF Line Endings', async function () { const expected = fs.readFileSync( './test/mocks/openapi-1.3.0-lf.yaml', 'utf-8', ); const filename = 'openapi.yaml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/openapi-1.2.3-lf.yaml', }, ], }); await exec({ packageFiles: [{ filename, type: 'openapi' }], bumpFiles: [{ filename, type: 'openapi' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in Maven `pom.xml` file with CRLF Line Endings', async function () { const expected = fs.readFileSync( './test/mocks/pom-6.4.0-crlf.xml', 'utf-8', ); const filename = 'pom.xml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/pom-6.3.1-crlf.xml', }, ], }); await exec({ packageFiles: [{ filename, type: 'maven' }], bumpFiles: [{ filename, type: 'maven' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in Maven `pom.xml` file with LF Line Endings', async function () { const expected = fs.readFileSync( './test/mocks/pom-6.4.0-lf.xml', 'utf-8', ); const filename = 'pom.xml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/pom-6.3.1-lf.xml', }, ], }); await exec({ packageFiles: [{ filename, type: 'maven' }], bumpFiles: [{ filename, type: 'maven' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in Gradle `build.gradle.kts` file', async function () { const expected = fs.readFileSync( './test/mocks/build-6.4.0.gradle.kts', 'utf-8', ); const filename = 'build.gradle.kts'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/build-6.3.1.gradle.kts', }, ], }); await exec({ packageFiles: [{ filename, type: 'gradle' }], bumpFiles: [{ filename, type: 'gradle' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in .NET `Project.csproj` file', async function () { const expected = fs.readFileSync( './test/mocks/Project-6.4.0.csproj', 'utf-8', ); const filename = 'Project.csproj'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/Project-6.3.1.csproj', }, ], }); await exec({ packageFiles: [{ filename, type: 'csproj' }], bumpFiles: [{ filename, type: 'csproj' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version # in npm-shrinkwrap.json', async function () { mock({ bump: 'minor', testFiles: [ { path: 'npm-shrinkwrap.json', value: { version: '1.0.0' }, }, ], tags: ['v1.0.0'], }); await exec(); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'npm-shrinkwrap.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); it('bumps version # in package-lock.json', async function () { mock({ bump: 'minor', testFiles: [ { path: '.gitignore', value: '', }, { path: 'package-lock.json', value: { version: '1.0.0' }, }, ], tags: ['v1.0.0'], }); await exec(); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0', filename: 'package-lock.json', }); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); }); it('bumps version in Dart `pubspec.yaml` file', async function () { const expected = fs.readFileSync( './test/mocks/pubspec-6.4.0.yaml', 'utf-8', ); const filename = 'pubspec.yaml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/pubspec-6.3.1.yaml', }, ], }); await exec({ packageFiles: [{ filename, type: 'yaml' }], bumpFiles: [{ filename, type: 'yaml' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); it('bumps version in Dart `pubspec.yaml` file with CRLF line endings', async function () { const expected = fs.readFileSync( './test/mocks/pubspec-6.4.0-crlf.yaml', 'utf-8', ); const filename = 'pubspec.yaml'; mock({ bump: 'minor', realTestFiles: [ { filename, path: './test/mocks/pubspec-6.3.1-crlf.yaml', }, ], }); await exec({ packageFiles: [{ filename, type: 'yaml' }], bumpFiles: [{ filename, type: 'yaml' }], }); // filePath is the first arg passed to writeFileSync const packageJsonWriteFileSynchCall = findWriteFileCallForPath({ writeFileSyncSpy, filename, }); if (!packageJsonWriteFileSynchCall) { throw new Error(`writeFileSynch not invoked with path ${filename}`); } const calledWithContentStr = packageJsonWriteFileSynchCall[1]; expect(calledWithContentStr).toEqual(expected); }); describe('skip', function () { it('allows bump and changelog generation to be skipped', async function () { const changelogContent = 'legacy header format<a name="1.0.0">\n'; mock({ bump: 'minor', changelog: 'foo\n', existingChangelog: changelogContent, }); await exec('--skip.bump true --skip.changelog true'); expect(writeFileSyncSpy).not.toHaveBeenCalledWith('package.json'); expect(writeFileSyncSpy).not.toHaveBeenCalledWith('CHANGELOG.md'); }); }); it('does not update files present in .gitignore', async function () { const DotGitIgnore = require('dotgitignore'); jest.mock('dotgitignore'); DotGitIgnore.mockImplementation(() => { return { ignore: (filename) => { if (filename === 'package-lock.json' || filename === 'bower.json') { return true; } return false; }, }; }); mock({ bump: 'minor', testFiles: [ { path: 'bower.json', value: { version: '1.0.0' }, }, { path: 'package-lock.json', value: { name: '@org/package', version: '1.0.0', lockfileVersion: 1, }, }, ], tags: ['v1.0.0'], }); await exec(); // does not bump these as in .gitignore expect(writeFileSyncSpy).not.toHaveBeenCalledWith('package-lock.json'); expect(writeFileSyncSpy).not.toHaveBeenCalledWith('bower.json'); // should still bump version in package.json verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '1.1.0' }); DotGitIgnore.mockRestore(); }); describe('configuration', function () { it('--header', async function () { mock({ bump: 'minor', existingChangelog: '' }); await exec('--header="# Welcome to our CHANGELOG.md"'); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: /# 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}"`); verifyNewChangelogContentMatches({ writeFileSyncSpy, expectedContent: `${format}:${prefix}`, }); }); }); describe('pre-major', function () { it('bumps the minor rather than major, if version < 1.0.0', async function () { mock({ bump: 'minor', testFiles: [ { path: 'package.json', value: { version: '0.5.0', repository: { url: 'https://github.com/yargs/yargs.git' }, }, }, ], }); await exec(); verifyPackageVersion({ writeFileSyncSpy, expectedVersion: '0.6.0' }); }); it('bumps major if --release-as=major specified, if version < 1.0.0', asy