diff --git a/package.json b/package.json index a3492406..9de8bdfb 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,20 @@ "commit" ], "dependencies": { - "chalk": "^5.2.0", + "chalk": "^5.3.0", + "chalk-template": "^1.1.0", "cosmiconfig": "^8.1.3", - "del": "^7.0.0", + "del": "^7.1.0", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", - "execa": "^7.1.1", - "exit-hook": "^3.2.0", + "execa": "^8.0.1", + "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", "has-yarn": "^3.0.0", - "hosted-git-info": "^6.1.1", + "hosted-git-info": "^7.0.0", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.6", + "inquirer": "^9.2.10", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -51,40 +52,49 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^5.1.0", - "meow": "^12.0.1", + "meow": "^12.1.1", "new-github-release-url": "^2.0.0", "npm-name": "^7.1.0", "onetime": "^6.0.0", "open": "^9.1.0", "ow": "^1.1.1", "p-memoize": "^7.1.1", - "p-timeout": "^6.1.1", + "p-timeout": "^6.1.2", "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", - "read-pkg-up": "^9.1.0", + "read-pkg": "^8.1.0", + "read-pkg-up": "^10.1.0", "rxjs": "^7.8.1", - "semver": "^7.5.1", + "semver": "^7.5.4", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" }, "devDependencies": { - "ava": "^5.3.0", + "@sindresorhus/is": "^6.0.0", + "@types/semver": "^7.5.1", + "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.2.3", + "esmock": "^2.3.8", "fs-extra": "^11.1.1", - "move-file": "^3.1.0", - "sinon": "^15.1.0", - "tempy": "^3.0.0", - "write-pkg": "^5.1.0", - "xo": "^0.54.2" + "map-obj": "^5.0.2", + "sinon": "^15.2.0", + "strip-ansi": "^7.1.0", + "tempy": "^3.1.0", + "write-pkg": "^6.0.0", + "xo": "^0.56.0" }, "ava": { + "files": [ + "!test/fixtures", + "!test/_helpers" + ], "environmentVariables": { "FORCE_HYPERLINK": "1" }, "nodeArguments": [ - "--loader=esmock" + "--loader=esmock", + "--no-warnings=ExperimentalWarning" ] } } diff --git a/readme.md b/readme.md index f04dcf7a..bc1fb62f 100644 --- a/readme.md +++ b/readme.md @@ -73,7 +73,7 @@ $ np --help $ np Version can be: - patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3 + major | minor | patch | premajor | preminor | prepatch | prerelease | 1.2.3 Options --any-branch Allow publishing from any branch @@ -87,7 +87,7 @@ $ np --help --no-yarn Don't use Yarn --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft - --release-draft-only Only opens a GitHub release draft + --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message. `%s` will be replaced with version. (default: '%s' with npm and 'v%s' with yarn) @@ -113,7 +113,7 @@ Run `np` without arguments to launch the interactive UI that guides you through Currently, these are the flags you can configure: - `anyBranch` - Allow publishing from any branch (`false` by default). -- `branch` - Name of the release branch (`master` by default). +- `branch` - Name of the release branch (`main` or `master` by default). - `cleanup` - Cleanup `node_modules` (`true` by default). - `tests` - Run `npm test` (`true` by default). - `yolo` - Skip cleanup and testing (`false` by default). @@ -346,6 +346,8 @@ npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-p "publishConfig": { "registry": "https://registry.npmjs.org" } + +Note: On `npm` v9+, the command has been changed to `npm access list collaborators my-awesome-package`. ``` ## Maintainers diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 47748504..e706f2ad 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -10,7 +10,7 @@ import config from './config.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -import Version from './version.js'; +import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; import np from './index.js'; @@ -19,7 +19,7 @@ const cli = meow(` $ np Version can be: - ${Version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 + ${SEMVER_INCREMENTS.join(' | ')} | 1.2.3 Options --any-branch Allow publishing from any branch @@ -56,18 +56,22 @@ const cli = meow(` }, cleanup: { type: 'boolean', + default: true, }, tests: { type: 'boolean', + default: true, }, yolo: { type: 'boolean', }, publish: { type: 'boolean', + default: true, }, releaseDraft: { type: 'boolean', + default: true, }, releaseDraftOnly: { type: 'boolean', @@ -77,6 +81,7 @@ const cli = meow(` }, yarn: { type: 'boolean', + default: hasYarn(), }, contents: { type: 'string', @@ -89,6 +94,7 @@ const cli = meow(` }, '2fa': { type: 'boolean', + default: true, }, message: { type: 'string', @@ -101,19 +107,8 @@ updateNotifier({pkg: cli.pkg}).notify(); try { const {pkg, rootDir} = await util.readPkg(cli.flags.contents); - const defaultFlags = { - cleanup: true, - tests: true, - publish: true, - releaseDraft: true, - yarn: hasYarn(), - '2fa': true, - }; - const localConfig = await config(rootDir); - const flags = { - ...defaultFlags, ...localConfig, ...cli.flags, }; @@ -125,20 +120,22 @@ try { const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; - const availability = flags.publish ? await npm.isPackageNameAvailable(pkg) : { + // TODO: does this need to run if `runPublish` is false? + const availability = runPublish ? await npm.isPackageNameAvailable(pkg) : { isAvailable: false, isUnknown: false, }; - // Use current (latest) version when 'releaseDraftOnly', otherwise use the first argument. - const version = flags.releaseDraftOnly ? pkg.version : (cli.input.length > 0 ? cli.input[0] : false); + // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. + const version = flags.releaseDraftOnly ? pkg.version : cli.input.at(0); + + const branch = flags.branch ?? await git.defaultBranch(); - const branch = flags.branch || await git.defaultBranch(); const options = await ui({ ...flags, + runPublish, availability, version, - runPublish, branch, }, {pkg, rootDir}); diff --git a/source/cli.js b/source/cli.js index 8c71be61..259300ce 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,14 +1,12 @@ #!/usr/bin/env node -import {fileURLToPath} from 'node:url'; import {debuglog} from 'node:util'; import importLocal from 'import-local'; import isInstalledGlobally from 'is-installed-globally'; -const __filename = fileURLToPath(import.meta.url); const log = debuglog('np'); // Prefer the local installation -if (!importLocal(__filename)) { +if (!importLocal(import.meta.url)) { if (isInstalledGlobally) { log('Using global install of np.'); } diff --git a/source/config.js b/source/config.js index 954b66d8..6aa41a57 100644 --- a/source/config.js +++ b/source/config.js @@ -2,7 +2,7 @@ import os from 'node:os'; import isInstalledGlobally from 'is-installed-globally'; import {cosmiconfig} from 'cosmiconfig'; -// TODO: remove when cosmiconfig/cosmiconfig#283 lands +// TODO: Remove when cosmiconfig/cosmiconfig#283 lands const loadESM = async filepath => { const module = await import(filepath); return module.default ?? module; diff --git a/source/git-util.js b/source/git-util.js index 7b114cb8..cfe36356 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -2,7 +2,7 @@ import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; -import Version from './version.js'; +import * as util from './util.js'; export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); @@ -33,11 +33,18 @@ export const newFilesSinceLastRelease = async rootDir => { }; export const readFileFromLastRelease = async file => { - const filePathFromRoot = path.relative(await root(), file); + const rootPath = await root(); + const filePathFromRoot = path.relative(rootPath, path.resolve(rootPath, file)); const {stdout: oldFile} = await execa('git', ['show', `${await latestTag()}:${filePathFromRoot}`]); return oldFile; }; +/** Returns an array of tags, sorted by creation date in ascending order. */ +const tagList = async () => { + const {stdout} = await execa('git', ['tag', '--sort=creatordate']); + return stdout ? stdout.split('\n') : []; +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); return stdout; @@ -97,12 +104,6 @@ export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { } }; -export const tagList = async () => { - // Returns the list of tags, sorted by creation date in ascending order. - const {stdout} = await execa('git', ['tag', '--sort=creatordate']); - return stdout.split('\n'); -}; - export const isHeadDetached = async () => { try { // Command will fail with code 1 if the HEAD is detached. @@ -113,7 +114,7 @@ export const isHeadDetached = async () => { } }; -export const isWorkingTreeClean = async () => { +const isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); if (status !== '') { @@ -182,7 +183,27 @@ export const fetch = async () => { await execa('git', ['fetch']); }; -export const tagExistsOnRemote = async tagName => { +const hasLocalBranch = async branch => { + try { + await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); + return true; + } catch { + return false; + } +}; + +export const defaultBranch = async () => { + for (const branch of ['main', 'master', 'gh-pages']) { + // eslint-disable-next-line no-await-in-loop + if (await hasLocalBranch(branch)) { + return branch; + } + } + + throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); +}; + +const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); @@ -202,26 +223,6 @@ export const tagExistsOnRemote = async tagName => { } }; -async function hasLocalBranch(branch) { - try { - await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); - return true; - } catch { - return false; - } -} - -export const defaultBranch = async () => { - for (const branch of ['main', 'master', 'gh-pages']) { - // eslint-disable-next-line no-await-in-loop - if (await hasLocalBranch(branch)) { - return branch; - } - } - - throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); -}; - export const verifyTagDoesNotExistOnRemote = async tagName => { if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); @@ -233,13 +234,17 @@ export const commitLogFromRevision = async revision => { return stdout; }; +const push = async (tagArg = '--follow-tags') => { + await execa('git', ['push', tagArg]); +}; + export const pushGraceful = async remoteIsOnGitHub => { try { await push(); } catch (error) { if (remoteIsOnGitHub && error.stderr && error.stderr.includes('GH006')) { // Try to push tags only, when commits can't be pushed due to branch protection - await execa('git', ['push', '--tags']); + await push('--tags'); return {pushed: 'tags', reason: 'Branch protection: np can`t push the commits. Push them manually.'}; } @@ -247,10 +252,6 @@ export const pushGraceful = async remoteIsOnGitHub => { } }; -export const push = async () => { - await execa('git', ['push', '--follow-tags']); -}; - export const deleteTag = async tagName => { await execa('git', ['tag', '--delete', tagName]); }; @@ -267,8 +268,7 @@ const gitVersion = async () => { export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); - - Version.verifyRequirementSatisfied('git', installedVersion); + util.validateEngineVersionSatisfies('git', installedVersion); }; export const checkIfFileGitIgnored = async pathToFile => { diff --git a/source/index.js b/source/index.js index 28a263cb..c4aa6c30 100644 --- a/source/index.js +++ b/source/index.js @@ -4,7 +4,6 @@ import {execa} from 'execa'; import {deleteAsync} from 'del'; import Listr from 'listr'; import {merge, throwError, catchError, filter, finalize} from 'rxjs'; -import {readPackageUp} from 'read-pkg-up'; import hasYarn from 'has-yarn'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; @@ -64,7 +63,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { try { // Verify that the package's version has been bumped before deleting the last tag and commit. - if (versionInLatestTag === util.readPkg().version && versionInLatestTag !== pkg.version) { + if (versionInLatestTag === util.readPkg(rootDir).version && versionInLatestTag !== pkg.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } @@ -85,7 +84,9 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { } else { console.log('\nAborted!'); } - }, {minimumWait: 2000}); + }, {wait: 2000}); + + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); const tasks = new Listr([ { @@ -97,13 +98,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { title: 'Git', task: () => gitTasks(options), }, - ], { - showSubtasks: false, - renderer: options.renderer ?? 'default', - }); - - if (runCleanup) { - tasks.add([ + ...runCleanup ? [ { title: 'Cleanup', enabled: () => !hasLockFile, @@ -136,11 +131,8 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return exec('npm', [...args, '--engine-strict']); }, }, - ]); - } - - if (runTests) { - tasks.add([ + ] : [], + ...runTests ? [ { title: 'Running tests using npm', enabled: () => options.yarn === false, @@ -159,10 +151,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }), ), }, - ]); - } - - tasks.add([ + ] : [], { title: 'Bumping version using Yarn', enabled: () => options.yarn === true, @@ -171,7 +160,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; if (options.message) { - previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + previewText += ` --message '${options.message.replaceAll('%s', input)}'`; } return `${previewText}.`; @@ -195,7 +184,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { let previewText = `[Preview] Command not executed: npm version ${input}`; if (options.message) { - previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + previewText += ` --message '${options.message.replaceAll('%s', input)}'`; } return `${previewText}.`; @@ -211,10 +200,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return exec('npm', args); }, }, - ]); - - if (options.runPublish) { - tasks.add([ + ...options.runPublish ? [ { title: `Publishing package using ${pkgManagerName}`, skip() { @@ -239,49 +225,37 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { ); }, }, - ]); - - const isExternalRegistry = npm.isExternalRegistry(pkg); - if (options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !isExternalRegistry) { - tasks.add([ - { - title: 'Enabling two-factor authentication', - skip() { - if (options.preview) { - const args = enable2fa.getEnable2faArgs(pkg.name, options); - return `[Preview] Command not executed: npm ${args.join(' ')}.`; - } - }, - task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + ...shouldEnable2FA ? [{ + title: 'Enabling two-factor authentication', + skip() { + if (options.preview) { + const args = enable2fa.getEnable2faArgs(pkg.name, options); + return `[Preview] Command not executed: npm ${args.join(' ')}.`; + } }, - ]); - } - } else { - publishStatus = 'SUCCESS'; - } - - tasks.add({ - title: 'Pushing tags', - async skip() { - if (!(await git.hasUpstream())) { - return 'Upstream branch not found; not pushing.'; - } + task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + }] : [], + ] : [], + { + title: 'Pushing tags', + async skip() { + if (!(await git.hasUpstream())) { + return 'Upstream branch not found; not pushing.'; + } - if (options.preview) { - return '[Preview] Command not executed: git push --follow-tags.'; - } + if (options.preview) { + return '[Preview] Command not executed: git push --follow-tags.'; + } - if (publishStatus === 'FAILED' && options.runPublish) { - return 'Couldn\'t publish package to npm; not pushing.'; - } - }, - async task() { - pushedObjects = await git.pushGraceful(isOnGitHub); + if (publishStatus === 'FAILED' && options.runPublish) { + return 'Couldn\'t publish package to npm; not pushing.'; + } + }, + async task() { + pushedObjects = await git.pushGraceful(isOnGitHub); + }, }, - }); - - if (options.releaseDraft) { - tasks.add({ + ...options.releaseDraft ? [{ title: 'Creating release draft on GitHub', enabled: () => isOnGitHub === true, skip() { @@ -289,8 +263,16 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return '[Preview] GitHub Releases draft will not be opened in preview mode.'; } }, + // TODO: parse version outside of index task: () => releaseTaskHelper(options, pkg), - }); + }] : [], + ], { + showSubtasks: false, + renderer: options.renderer ?? 'default', + }); + + if (!options.runPublish) { + publishStatus = 'SUCCESS'; } await tasks.run(); @@ -299,7 +281,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } - const {packageJson: newPkg} = await readPackageUp(); + const {pkg: newPkg} = await util.readPkg(); return newPkg; }; diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 429746f1..383182ec 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -1,12 +1,14 @@ import {execa} from 'execa'; import {from, catchError} from 'rxjs'; -import semver from 'semver'; +import Version from '../version.js'; import handleNpmError from './handle-npm-error.js'; import {version as npmVersionCheck} from './util.js'; export const getEnable2faArgs = async (packageName, options) => { const npmVersion = await npmVersionCheck(); - const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'set', 'mfa=publish', packageName] : ['access', '2fa-required', packageName]; + const args = new Version(npmVersion).satisfies('>=9.0.0') + ? ['access', 'set', 'mfa=publish', packageName] + : ['access', '2fa-required', packageName]; if (options && options.otp) { args.push('--otp', options.otp); diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 7ec39c88..4188ea54 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -27,7 +27,7 @@ const handleNpmError = (error, task, message, executor) => { // Attempting to privately publish a scoped package without the correct npm plan // https://stackoverflow.com/a/44862841/10292952 if (error.code === 402 || error.stderr.includes('npm ERR! 402 Payment Required')) { - throw new Error('You cannot publish a privately scoped package without a paid plan. Did you mean to publish publicly?'); + throw new Error('You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'); } return throwError(() => error); diff --git a/source/npm/util.js b/source/npm/util.js index fec23fd9..792e1379 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -4,9 +4,14 @@ import {execa} from 'execa'; import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; -import chalk from 'chalk'; -import semver from 'semver'; +import chalk from 'chalk-template'; import Version from '../version.js'; +import * as util from '../util.js'; + +export const version = async () => { + const {stdout} = await execa('npm', ['--version']); + return stdout; +}; export const checkConnection = () => pTimeout( (async () => { @@ -33,18 +38,25 @@ export const username = async ({externalRegistry}) => { const {stdout} = await execa('npm', args); return stdout; } catch (error) { - throw new Error(/ENEEDAUTH/.test(error.stderr) + const message = /ENEEDAUTH/.test(error.stderr) ? 'You must be logged in. Use `npm login` and try again.' - : 'Authentication error. Use `npm whoami` to troubleshoot.'); + : 'Authentication error. Use `npm whoami` to troubleshoot.'; + throw new Error(message); } }; +export const isExternalRegistry = pkg => typeof pkg.publishConfig?.registry === 'string'; + export const collaborators = async pkg => { const packageName = pkg.name; ow(packageName, ow.string); const npmVersion = await version(); - const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; + // TODO: Remove old command when targeting Node.js 18 + const args = new Version(npmVersion).satisfies('>=9.0.0') + ? ['access', 'list', 'collaborators', packageName, '--json'] + : ['access', 'ls-collaborators', packageName]; + if (isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); } @@ -116,24 +128,17 @@ export const isPackageNameAvailable = async pkg => { return availability; }; -export const isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; - -export const version = async () => { - const {stdout} = await execa('npm', ['--version']); - return stdout; -}; - export const verifyRecentNpmVersion = async () => { const npmVersion = await version(); - Version.verifyRequirementSatisfied('npm', npmVersion); + util.validateEngineVersionSatisfies('npm', npmVersion); }; -export const checkIgnoreStrategy = ({files}, rootDir) => { - const npmignoreExistsInPackageRootDir = pathExists(path.resolve(rootDir, '.npmignore')); +export const checkIgnoreStrategy = async ({files}, rootDir) => { + const npmignoreExistsInPackageRootDir = await pathExists(path.resolve(rootDir, '.npmignore')); if (!files && !npmignoreExistsInPackageRootDir) { - console.log(` - \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. + console.log(chalk` + \n{bold.yellow Warning:} No {bold.cyan files} field specified in {bold.magenta package.json} nor is a {bold.magenta .npmignore} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 50006c8d..4f9fc597 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -2,13 +2,13 @@ import process from 'node:process'; import Listr from 'listr'; import {execa} from 'execa'; import Version from './version.js'; +import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -import {getTagVersionPrefix} from './util.js'; const prerequisiteTasks = (input, pkg, options) => { const isExternalRegistry = npm.isExternalRegistry(pkg); - let newVersion = null; + let newVersion; const tasks = [ { @@ -25,7 +25,7 @@ const prerequisiteTasks = (input, pkg, options) => { enabled: () => options.yarn === true, async task() { const {stdout: yarnVersion} = await execa('yarn', ['--version']); - Version.verifyRequirementSatisfied('yarn', yarnVersion); + util.validateEngineVersionSatisfies('yarn', yarnVersion); }, }, { @@ -59,13 +59,15 @@ const prerequisiteTasks = (input, pkg, options) => { { title: 'Validate version', task() { - newVersion = Version.getAndValidateNewVersionFrom(input, pkg.version); + newVersion = input instanceof Version + ? input + : new Version(pkg.version).setFrom(input); }, }, { title: 'Check for pre-release version', task() { - if (!pkg.private && new Version(newVersion).isPrerelease() && !options.tag) { + if (!pkg.private && newVersion.isPrerelease() && !options.tag) { throw new Error('You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'); } }, @@ -75,7 +77,7 @@ const prerequisiteTasks = (input, pkg, options) => { async task() { await git.fetch(); - const tagPrefix = await getTagVersionPrefix(options); + const tagPrefix = await util.getTagVersionPrefix(options); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); }, diff --git a/source/pretty-version-diff.js b/source/pretty-version-diff.js deleted file mode 100644 index 059fa219..00000000 --- a/source/pretty-version-diff.js +++ /dev/null @@ -1,26 +0,0 @@ -import chalk from 'chalk'; -import Version from './version.js'; - -const prettyVersionDiff = (oldVersion, inc) => { - const newVersion = new Version(oldVersion).getNewVersionFrom(inc).split('.'); - oldVersion = oldVersion.split('.'); - let firstVersionChange = false; - const output = []; - - for (const [i, element] of newVersion.entries()) { - if ((element !== oldVersion[i] && !firstVersionChange)) { - output.push(`${chalk.dim.cyan(element)}`); - firstVersionChange = true; - } else if (element.indexOf('-') >= 1) { - let preVersion = []; - preVersion = element.split('-'); - output.push(`${chalk.dim.cyan(`${preVersion[0]}-${preVersion[1]}`)}`); - } else { - output.push(chalk.reset.dim(element)); - } - } - - return output.join(chalk.reset.dim('.')); -}; - -export default prettyVersionDiff; diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 2e71aca9..51f62058 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,18 +4,17 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { - const newVersion = new Version(pkg.version).getNewVersionFrom(options.version); - let tag = await getTagVersionPrefix(options) + newVersion; - const isPreRelease = new Version(options.version).isPrerelease(); - if (isPreRelease) { - tag += await getPreReleasePrefix(options); - } + const newVersion = options.releaseDraftOnly + ? new Version(pkg.version) + : new Version(pkg.version).setFrom(options.version, {prereleasePrefix: await getPreReleasePrefix(options)}); + + const tag = await getTagVersionPrefix(options) + newVersion.toString(); const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, body: options.releaseNotes(tag), - isPrerelease: isPreRelease, + isPrerelease: newVersion.isPrerelease(), }); await open(url); diff --git a/source/ui.js b/source/ui.js index 6489ca31..29f1c980 100644 --- a/source/ui.js +++ b/source/ui.js @@ -4,11 +4,10 @@ import githubUrlFromGit from 'github-url-from-git'; import {htmlEscape} from 'escape-goat'; import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; +import Version, {SEMVER_INCREMENTS} from './version.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -import Version from './version.js'; -import prettyVersionDiff from './pretty-version-diff.js'; const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => { const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit(); @@ -130,7 +129,7 @@ const ui = async (options, {pkg, rootDir}) => { const releaseBranch = options.branch; if (options.runPublish) { - npm.checkIgnoreStrategy(pkg, rootDir); + await npm.checkIgnoreStrategy(pkg, rootDir); const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, rootDir); if (!answerIgnoredFiles) { @@ -144,8 +143,9 @@ const ui = async (options, {pkg, rootDir}) => { if (options.releaseDraftOnly) { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); } else { - const newVersion = options.version ? Version.getAndValidateNewVersionFrom(options.version, oldVersion) : undefined; - const versionText = chalk.dim(`(current: ${oldVersion}${newVersion ? `, next: ${prettyVersionDiff(oldVersion, newVersion)}` : ''}${chalk.dim(')')}`); + const versionText = options.version + ? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion, options.version, {prereleasePrefix: await util.getPreReleasePrefix(options)}).format()})`) + : chalk.dim(`(current: ${oldVersion})`); console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); } @@ -170,6 +170,7 @@ const ui = async (options, {pkg, rootDir}) => { } } + // Non-interactive mode - return before prompting if (options.version) { return { ...options, @@ -214,44 +215,58 @@ const ui = async (options, {pkg, rootDir}) => { } } + const needsPrereleaseTag = answers => options.runPublish && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) && !options.tag; + const canBePublishedPublicly = options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg); + const answers = await inquirer.prompt({ version: { type: 'list', - message: 'Select semver increment or specify new version', - pageSize: Version.SEMVER_INCREMENTS.length + 2, - choices: [...Version.SEMVER_INCREMENTS - .map(inc => ({ - name: `${inc} ${prettyVersionDiff(oldVersion, inc)}`, + message: 'Select SemVer increment or specify new version', + pageSize: SEMVER_INCREMENTS.length + 2, + choices: [ + ...SEMVER_INCREMENTS.map(inc => ({ // TODO: prerelease prefix here too + name: `${inc} ${new Version(oldVersion, inc).format()}`, value: inc, })), - new inquirer.Separator(), - { - name: 'Other (specify)', - value: null, - }], - filter: input => Version.isValidInput(input) ? new Version(oldVersion).getNewVersionFrom(input) : input, + new inquirer.Separator(), + { + name: 'Other (specify)', + value: undefined, + }, + ], + filter: input => input ? new Version(oldVersion, input) : input, }, customVersion: { type: 'input', message: 'Version', - when: answers => !answers.version, - filter: input => Version.isValidInput(input) ? new Version(pkg.version).getNewVersionFrom(input) : input, - validate(input) { - if (!Version.isValidInput(input)) { - return 'Please specify a valid semver, for example, `1.2.3`. See https://semver.org'; + when: answers => answers.version === undefined, + filter(input) { + if (SEMVER_INCREMENTS.includes(input)) { + throw new Error('Custom version should not be a SemVer increment.'); } - if (new Version(oldVersion).isLowerThanOrEqualTo(input)) { - return `Version must be greater than ${oldVersion}`; + const version = new Version(oldVersion); + + try { + // Version error handling does validation + version.setFrom(input); + } catch (error) { + if (error.message.includes('valid SemVer version')) { + throw new Error(`Custom version ${input} should be a valid SemVer version.`); + } + + error.message = error.message.replace('New', 'Custom'); + + throw error; } - return true; + return version; }, }, tag: { type: 'list', message: 'How should this pre-release version be tagged in npm?', - when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag, + when: answers => needsPrereleaseTag(answers), async choices() { const existingPrereleaseTags = await npm.prereleaseTags(pkg.name); @@ -260,7 +275,7 @@ const ui = async (options, {pkg, rootDir}) => { new inquirer.Separator(), { name: 'Other (specify)', - value: null, + value: undefined, }, ]; }, @@ -268,7 +283,7 @@ const ui = async (options, {pkg, rootDir}) => { customTag: { type: 'input', message: 'Tag', - when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag && !answers.tag, + when: answers => answers.tag === undefined && needsPrereleaseTag(answers), validate(input) { if (input.length === 0) { return 'Please specify a tag, for example, `next`.'; @@ -283,7 +298,7 @@ const ui = async (options, {pkg, rootDir}) => { }, publishScoped: { type: 'confirm', - when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg), + when: isScoped(pkg.name) && canBePublishedPublicly, message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, diff --git a/source/util.js b/source/util.js index 348bb13d..8d31e6ec 100644 --- a/source/util.js +++ b/source/util.js @@ -1,28 +1,33 @@ +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {readPackageUp} from 'read-pkg-up'; +import {parsePackage} from 'read-pkg'; import issueRegex from 'issue-regex'; import terminalLink from 'terminal-link'; import {execa} from 'execa'; import pMemoize from 'p-memoize'; import ow from 'ow'; import chalk from 'chalk'; -import {packageDirectory} from 'pkg-dir'; +import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -export const readPkg = async packagePath => { - packagePath = packagePath ? await packageDirectory(packagePath) : await packageDirectory(); - if (!packagePath) { +export const readPkg = async (packagePath = process.cwd()) => { + const packageResult = await readPackageUp({cwd: packagePath}); + + if (!packageResult) { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - const {packageJson, path: pkgPath} = await readPackageUp({ - cwd: packagePath, - }); - - return {pkg: packageJson, rootDir: path.dirname(pkgPath)}; + return {pkg: packageResult.packageJson, rootDir: path.dirname(packageResult.path)}; }; +const _npRootDir = fileURLToPath(new URL('..', import.meta.url)); + +// Re-define `npRootDir` for trailing slash consistency +export const {pkg: npPkg, rootDir: npRootDir} = await readPkg(_npRootDir); + export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { return message; @@ -59,12 +64,10 @@ export const getTagVersionPrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'version-tag-prefix']); - return stdout; - } + const {stdout} = options.yarn + ? await execa('yarn', ['config', 'get', 'version-tag-prefix']) + : await execa('npm', ['config', 'get', 'tag-version-prefix']); - const {stdout} = await execa('npm', ['config', 'get', 'tag-version-prefix']); return stdout; } catch { return 'v'; @@ -84,16 +87,16 @@ export const getNewFiles = async rootDir => { }; export const getNewDependencies = async (newPkg, rootDir) => { - let oldPkg; + let oldPkgFile; try { - oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + oldPkgFile = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); } catch { // Handle first time publish return Object.keys(newPkg.dependencies ?? {}); } - oldPkg = JSON.parse(oldPkg); + const oldPkg = parsePackage(oldPkgFile); const newDependencies = []; @@ -110,22 +113,18 @@ export const getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } - - return ''; - } - - const {stdout} = await execa('npm', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } + const packageManager = options.yarn ? 'yarn' : 'npm'; + const {stdout} = await execa(packageManager, ['config', 'get', 'preid']); - return ''; + return stdout === 'undefined' ? '' : stdout; } catch { return ''; } }); + +export const validateEngineVersionSatisfies = (engine, version) => { + const engineRange = npPkg.engines[engine]; + if (!new Version(version).satisfies(engineRange)) { + throw new Error(`\`np\` requires ${engine} ${engineRange}`); + } +}; diff --git a/source/version.js b/source/version.js index c395f268..82b21ce4 100644 --- a/source/version.js +++ b/source/version.js @@ -1,84 +1,190 @@ import semver from 'semver'; -import {readPackageUp} from 'read-pkg-up'; +import {template as chalk} from 'chalk-template'; -const {packageJson: pkg} = await readPackageUp(); +/** @type {string[]} Allowed `SemVer` release types. */ +export const SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; +export const SEMVER_INCREMENTS_LIST = SEMVER_INCREMENTS.join(', '); +const SEMVER_INCREMENTS_LIST_LAST_OR = `${SEMVER_INCREMENTS.slice(0, -1).join(', ')}, or ${SEMVER_INCREMENTS.slice(-1)}`; + +/** @typedef {semver.SemVer} SemVerInstance */ +/** @typedef {semver.ReleaseType} SemVerIncrement */ +/** @typedef {import('chalk').ColorName | import('chalk').ModifierName} ColorName */ + +/** @param {string} input @returns {input is SemVerIncrement} */ +const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); + +/** @param {string} input */ +const isInvalidSemVerVersion = input => Boolean(!semver.valid(input)); + +/** +Formats the first difference between two versions to the given `diffColor`. Useful for `prerelease` diffs. + +@param {string[]} current @param {string[]} previous @param {ColorName} diffColor +*/ +const formatFirstDifference = (current, previous, diffColor) => { + const firstDifferenceIndex = current.findIndex((part, i) => previous.at(i) !== part); + current[firstDifferenceIndex] = `{${diffColor} ${current.at(firstDifferenceIndex)}}`; + return current.join('.'); +}; export default class Version { - constructor(version) { - this.version = version; + /** @type {SemVerInstance} */ + #version; + + /** @type {SemVerIncrement | undefined} */ + #diff = undefined; + + /** @type {string | undefined} */ + #prereleasePrefix = undefined; + + toString() { + return this.#version.version; } - isPrerelease() { - return Boolean(semver.prerelease(this.version)); + /** + Sets `this.#version` to the given version. + + @param {string} version + @throws If `version` is an invalid `SemVer` version. + */ + #trySetVersion(version) { + this.#version = semver.parse(version); + + if (this.#version === null) { + throw new Error(`Version ${version} should be a valid SemVer version.`); + } } - satisfies(range) { - Version.validate(this.version); - return semver.satisfies(this.version, range, { - includePrerelease: true, - }); + /** + @param {string} version - A valid `SemVer` version. + @param {SemVerIncrement} [increment] - Optionally increment `version`. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + */ + constructor(version, increment, {prereleasePrefix} = {}) { + this.#prereleasePrefix = prereleasePrefix; + this.#trySetVersion(version); + + if (increment) { + if (!isSemVerIncrement(increment)) { + throw new Error(`Increment ${increment} should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); + } + + this.setFrom(increment); + } } - getNewVersionFrom(input) { - Version.validate(this.version); - if (!Version.isValidInput(input)) { - throw new Error(`Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); + /** + Sets a new version based on `input`. If `input` is a valid `SemVer` increment, the current version will be incremented by that amount. If `input` is a valid `SemVer` version, the current version will be set to `input` if it is greater than the current version. + + @param {string | SemVerIncrement} input - A new valid `SemVer` version or a `SemVer` increment to increase the current version by. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + @throws If `input` is not a valid `SemVer` version or increment, or if `input` is a valid `SemVer` version but is not greater than the current version. + */ + setFrom(input, {prereleasePrefix = ''} = {}) { + this.#prereleasePrefix ??= prereleasePrefix; + const previousVersion = this.toString(); + + if (isSemVerIncrement(input)) { + this.#version.inc(input, this.#prereleasePrefix); + } else { + if (isInvalidSemVerVersion(input)) { + throw new Error(`New version ${input} should either be one of ${SEMVER_INCREMENTS_LIST}, or a valid SemVer version.`); + } + + if (this.#isGreaterThanOrEqualTo(input)) { + throw new Error(`New version ${input} should be higher than current version ${this.toString()}.`); + } + + this.#trySetVersion(input); } - return Version.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; + // Set `this.#diff` to format version diffs + this.#diff = semver.diff(previousVersion, this.#version); + return this; } - isGreaterThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + /** + Formats the current version with `options.color`, pretty-printing the version's diff with `options.diffColor` if possible. - return semver.gte(otherVersion, this.version); - } + If the current version has never been changed, providing `options.previousVersion` will allow pretty-printing the diff. It must be provided to format diffs between `prerelease` versions. - isLowerThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + @param {object} options + @param {ColorName} [options.color = 'dim'] + @param {ColorName} [options.diffColor = 'cyan'] + @param {string} [options.prereleasePrefix] + @returns {string} A color-formatted version string. + */ + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { + if (typeof previousVersion === 'string') { + const previousSemver = semver.parse(previousVersion); - return semver.lte(otherVersion, this.version); - } + if (previousSemver === null) { + throw new Error(`Previous version ${previousVersion} should be a valid SemVer version.`); + } - static SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; - static PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; + previousVersion = previousSemver; + } - static isPrereleaseOrIncrement = input => new Version(input).isPrerelease() || Version.PRERELEASE_VERSIONS.includes(input); + if (!this.#diff) { + if (!previousVersion) { + return chalk(`{${color} ${this.toString()}}`); + } - static isValidVersion = input => Boolean(semver.valid(input)); + this.#diff = semver.diff(previousVersion, this.#version); + } - static isValidInput = input => Version.SEMVER_INCREMENTS.includes(input) || Version.isValidVersion(input); + const {major, minor, patch, prerelease} = this.#version; + const previousPrerelease = semver.prerelease(previousVersion); - static validate(version) { - if (!Version.isValidVersion(version)) { - throw new Error('Version should be a valid semver version.'); + if (prerelease && previousPrerelease) { + const prereleaseDiff = formatFirstDifference(prerelease, previousPrerelease, diffColor); + return chalk(`{${color} ${major}.${minor}.${patch}-${prereleaseDiff}}`); } + + /* eslint-disable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ + return ( + this.#diff === 'major' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}}`) : + this.#diff === 'minor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}}`) : + this.#diff === 'patch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}}`) : + this.#diff === 'premajor' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'preminor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'prepatch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' + ); + /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ } - static verifyRequirementSatisfied(dependency, version) { - if (!pkg.engines?.node) { - throw new Error('Please include a `engines.node` field in your package.json'); - } + /** + If the current version satisifes the given `SemVer` range. - const versionRange = pkg.engines?.[dependency]; - if (!versionRange) { - return; + @param {string} range + @throws If `range` is invalid. + */ + satisfies(range) { + if (!semver.validRange(range)) { + throw new Error(`Range ${range} is not a valid SemVer range.`); } - if (!new Version(version).satisfies(versionRange)) { - throw new Error(`Please upgrade to ${dependency}${versionRange}`); - } + return semver.satisfies(this.#version, range, { + includePrerelease: true, + }); } - static getAndValidateNewVersionFrom(input, version) { - const newVersion = new Version(version).getNewVersionFrom(input); + /** + If the current version has any `prerelease` components. + */ + isPrerelease() { + return Boolean(semver.prerelease(this.#version)); + } - if (new Version(version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); - } + /** + If the current version is the same as or higher than the given version. - return newVersion; + @param {string} otherVersion + */ + #isGreaterThanOrEqualTo(otherVersion) { + return semver.gte(this.#version, otherVersion); } } diff --git a/test/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts new file mode 100644 index 00000000..5bbd4f81 --- /dev/null +++ b/test/_helpers/integration-test.d.ts @@ -0,0 +1,32 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {Execa$} from 'execa'; + +type Context = { + firstCommitMessage: string; + getCommitMessage: (sha: string) => Promise; + createFile: (file: string, content?: string) => Promise; + commitNewFile: () => Promise<{ + sha: string; + commitMessage: string; + }>; +}; + +type CommandsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; +}]; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + testedModule: MockType; + $$: Execa$; + temporaryDir: string; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: (...arguments_: CommandsFnParameters) => Promise, + assertions: (...arguments_: AssertionsFnParameters) => Promise, +], Context>; + +export function _createFixture(source: string): CreateFixtureMacro; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js new file mode 100644 index 00000000..1e051589 --- /dev/null +++ b/test/_helpers/integration-test.js @@ -0,0 +1,66 @@ +/* eslint-disable ava/no-ignored-test-files */ +import crypto from 'node:crypto'; +import path from 'node:path'; +import fs from 'fs-extra'; +import test from 'ava'; +import esmock from 'esmock'; +import {$, execa} from 'execa'; +import {temporaryDirectoryTask} from 'tempy'; + +const createEmptyGitRepo = async ($$, temporaryDir) => { + const firstCommitMessage = '"init1"'; + + await $$`git init`; + + // `git tag` needs an initial commit + await fs.createFile(path.resolve(temporaryDir, 'temp')); + await $$`git add temp`; + await $$`git commit -m ${firstCommitMessage}`; + await $$`git rm temp`; + await $$`git commit -m "init2"`; + + return firstCommitMessage; +}; + +export const createIntegrationTest = async (t, assertions) => { + await temporaryDirectoryTask(async temporaryDir => { + const $$ = $({cwd: temporaryDir}); + + t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDir); + + // From https://stackoverflow.com/a/3357357/10292952 + t.context.getCommitMessage = async sha => { + const {stdout: commitMessage} = await $$`git log --format=%B -n 1 ${sha}`; + return commitMessage.trim(); + }; + + t.context.createFile = async (file, content = '') => fs.outputFile(path.resolve(temporaryDir, file), content); + + t.context.commitNewFile = async () => { + await t.context.createFile(`new-${crypto.randomUUID()}`); + await $$`git add .`; + await $$`git commit -m "added"`; + + const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; + + return { + sha: lastCommitSha, + commitMessage: await t.context.getCommitMessage(lastCommitSha), + }; + }; + + await assertions({$$, temporaryDir}); + }); +}; + +export const _createFixture = source => test.macro(async (t, commands, assertions) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + const testedModule = await esmock(source, {}, { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + }); + + await commands({t, $$, temporaryDir}); + await assertions({t, testedModule, $$, temporaryDir}); + }); +}); diff --git a/test/fixtures/listr-renderer.js b/test/_helpers/listr-renderer.js similarity index 100% rename from test/fixtures/listr-renderer.js rename to test/_helpers/listr-renderer.js diff --git a/test/_helpers/listr.js b/test/_helpers/listr.js new file mode 100644 index 00000000..84b10b02 --- /dev/null +++ b/test/_helpers/listr.js @@ -0,0 +1,20 @@ +import {SilentRenderer} from './listr-renderer.js'; + +export const run = async listr => { + listr.setRenderer(SilentRenderer); + await listr.run(); +}; + +export const assertTaskFailed = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(task.hasFailed(), `Task '${taskTitle}' did not fail!`); +}; + +export const assertTaskDisabled = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(!task.isEnabled(), `Task '${taskTitle}' was enabled!`); +}; + +export const assertTaskDoesntExist = (t, taskTitle) => { + t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `Task '${taskTitle}' exists!`); +}; diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js new file mode 100644 index 00000000..9a4c1d92 --- /dev/null +++ b/test/_helpers/mock-inquirer.js @@ -0,0 +1,223 @@ +import esmock from 'esmock'; +import is from '@sindresorhus/is'; +import stripAnsi from 'strip-ansi'; +import mapObject from 'map-obj'; + +/** @typedef {import('ava').ExecutionContext>} ExecutionContext */ +/** @typedef {string | boolean} ShortAnswer */ +/** @typedef {Record<'input' | 'error', string> | Record<'choice', string> | Record<'confirm', boolean>} LongAnswer */ +/** @typedef {ShortAnswer | LongAnswer} Answer */ +/** @typedef {Record} Answers */ +/** @typedef {import('inquirer').DistinctQuestion & {name?: never}} Prompt */ + +/** +Mocks `inquirer.prompt` and answers each prompt in the program with the provided `inputAnswers`. + +This only handles prompts of type `input`, `list`, and `confirm`. If other prompt types are added, they must be implemented here. + +Logs for debugging are outputted on test failure. + +@see https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 + +@param {object} o Test input and actual prompts +@param {ExecutionContext} o.t +@param {Answers} o.inputAnswers Test input +@param {Record | Prompt[]} o.prompts Actual prompts +*/ +const mockPrompt = async ({t, inputAnswers, prompts}) => { + const answers = {}; + + // Ensure `prompts` is an object + if (Array.isArray(prompts)) { + const promptsObject = {}; + + for (const prompt of prompts) { + promptsObject[prompt.name] = prompt; + } + + prompts = promptsObject; + } + + t.log('prompts:', Object.keys(prompts)); + + /* eslint-disable no-await-in-loop */ + for (const [name, prompt] of Object.entries(prompts)) { + if (prompt.when !== undefined) { + if (is.boolean(prompt.when) && !prompt.when) { + t.log(`skipping prompt '${name}'`); + continue; + } + + if (is.function_(prompt.when) && !prompt.when(answers)) { + t.log(`skipping prompt '${name}'`); + continue; + } + } + + t.log(`getting input for prompt '${name}'`); + + const setValue = value => { + if (prompt.validate) { + const result = prompt.validate(value); + + if (result !== true) { + if (is.string(result)) { + throw new Error(result); + } + + if (result === false) { + throw new Error('You must provide a valid value'); + } + } + } + + if (is.string(value)) { + t.log(`filtering value '${value}' for prompt '${name}'`); + } else { + t.log(`filtering value for prompt '${name}':`, value); + } + + answers[name] = prompt.filter + ? prompt.filter(value) // eslint-disable-line unicorn/no-array-callback-reference + : value; + + t.log(`got value '${answers[name]}' for prompt '${name}'`); + }; + + /** @param {Answer} input */ + const chooseValue = async input => { + t.is(prompt.type, 'list'); + let choices; + + if (is.asyncFunction(prompt.choices)) { + choices = await prompt.choices(answers); + } else if (is.function_(prompt.choices)) { + choices = prompt.choices(answers); + } else { + choices = prompt.choices; + } + + t.log(`choices for prompt '${name}':`, choices); + + const value = choices.find(choice => { + if (is.object(choice)) { + return choice.name && stripAnsi(choice.name).startsWith(input.choice ?? input); + } + + if (is.string(choice)) { + return stripAnsi(choice).startsWith(input.choice ?? input); + } + + return false; + }); + + // `value.value` could exist but literally be `undefined` + setValue(Object.hasOwn(value, 'value') ? value.value : value); + }; + + const input = inputAnswers[name]; + + if (is.undefined(input)) { + t.fail(`Expected input for prompt '${name}'.`); + continue; + } + + if (is.string(input)) { + t.log(`found input for prompt '${name}': '${input}'`); + } else { + t.log(`found input for prompt '${name}':`, input); + } + + /** @param {Answer} input */ + const handleInput = async input => { + if (is.string(input)) { + if (['input'].includes(prompt.type)) { + setValue(input); + } else if (['list'].includes(prompt.type)) { + return chooseValue(input); + } else { + t.fail('Incorrect input type'); + } + + return; + } + + if (input.input !== undefined) { + t.is(prompt.type, 'input'); + setValue(input.input); + return; + } + + if (input.choice !== undefined) { + await chooseValue(input); + return; + } + + if (is.boolean(input.confirm) || is.boolean(input)) { + t.is(prompt.type, 'confirm'); + setValue(input.confirm ?? input); + } + }; + + // Multiple inputs for the given prompt + if (is.array(input)) { + for (const attempt of input) { + if (attempt.error) { + await t.throwsAsync( + handleInput(attempt), + {message: attempt.error}, + ); + } else { + await handleInput(attempt); + } + } + } + + await handleInput(input); + } + /* eslint-enable no-await-in-loop */ + + return answers; +}; + +/** +Fixes relative module paths for use with `esmock`. Allows specifiying the same relative location in test files as in source files. +@param {import('esmock').MockMap} mocks +*/ +const fixRelativeMocks = mocks => mapObject(mocks, (key, value) => [key.replace('./', '../../source/'), value]); + +/** +Mocks `inquirer` for testing `source/ui.js`. + +@param {object} o Test input and optional global mocks +@param {ExecutionContext} o.t +@param {Answers} o.answers Test input +@param {import('esmock').MockMap} [o.mocks] Optional global mocks +*/ +export const mockInquirer = async ({t, answers, mocks = {}}) => { + /** @type {string[]} */ + const logs = []; + + /** @type {import('../../source/ui.js')} */ + const ui = await esmock('../../source/ui.js', import.meta.url, { + inquirer: { + async prompt(prompts) { + let uiAnswers = {}; + + const assertions = await t.try(async tt => { + uiAnswers = await mockPrompt({t: tt, inputAnswers: answers, prompts}); + }); + + assertions.commit({retainLogs: !assertions.passed}); + return uiAnswers; + }, + }, + }, { + ...fixRelativeMocks(mocks), + import: { + console: {log: (...args) => logs.push(...args)}, + }, + }); + + return {ui, logs}; +}; diff --git a/test/_helpers/stub-execa.d.ts b/test/_helpers/stub-execa.d.ts new file mode 100644 index 00000000..ccf04da6 --- /dev/null +++ b/test/_helpers/stub-execa.d.ts @@ -0,0 +1,14 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {ExecaReturnValue} from 'execa'; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + testedModule: MockType; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: ExecaReturnValue[], + assertions: (...arguments_: AssertionsFnParameters) => Promise, +]>; + +export function _createFixture(source: string, importMeta: string): CreateFixtureMacro; diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js new file mode 100644 index 00000000..f5c0973e --- /dev/null +++ b/test/_helpers/stub-execa.js @@ -0,0 +1,50 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import {execa} from 'execa'; + +/** +Stubs `execa` to return a specific result when called with the given commands. + +A command passes if its exit code is 0, or if there's no exit code and no stderr. + +Resolves or throws the given result. + +@param {import('execa').ExecaReturnValue[]} commands +*/ +const makeExecaStub = commands => { + const stub = sinon.stub(); + + for (const result of commands) { + const [command, ...commandArgs] = result.command.split(' '); + + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); + + if (passes) { + stub.withArgs(command, commandArgs).resolves(result); + } else { + stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + } + } + + return stub; +}; + +const stubExeca = commands => { + const execaStub = makeExecaStub(commands); + + return { + execa: { + async execa(...args) { + execaStub.resolves(execa(...args)); + return execaStub(...args); + }, + }, + }; +}; + +export const _createFixture = (source, importMeta) => test.macro(async (t, commands, assertions) => { + const testedModule = await esmock(source, importMeta, {}, stubExeca(commands)); + await assertions({t, testedModule}); +}); diff --git a/test/_helpers/util.js b/test/_helpers/util.js new file mode 100644 index 00000000..60600052 --- /dev/null +++ b/test/_helpers/util.js @@ -0,0 +1,12 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +export const runIfExists = async (func, ...args) => { + if (typeof func === 'function') { + await func(...args); + } +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const getFixture = fixture => path.resolve(__dirname, '..', 'fixtures', ...fixture.split('/')); diff --git a/test/_helpers/verify-cli.d.ts b/test/_helpers/verify-cli.d.ts new file mode 100644 index 00000000..8b695ddc --- /dev/null +++ b/test/_helpers/verify-cli.d.ts @@ -0,0 +1,10 @@ +import type {Macro, ExecutionContext} from 'ava'; + +type VerifyCliMacro = Macro<[ + binPath: string, + args: string | string[], + expectedLines: string[], +], Record>; + +export const cliPasses: VerifyCliMacro; +export const cliFails: VerifyCliMacro; diff --git a/test/_helpers/verify-cli.js b/test/_helpers/verify-cli.js new file mode 100644 index 00000000..9d3985a8 --- /dev/null +++ b/test/_helpers/verify-cli.js @@ -0,0 +1,16 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import {execa} from 'execa'; + +const trim = stdout => stdout.split('\n').map(line => line.trim()); + +const _verifyCli = shouldPass => test.macro(async (t, binPath, args, expectedLines) => { + const {exitCode, stdout} = await execa(binPath, [args].flat(), {reject: false}); + const receivedLines = trim(stdout); + + t.deepEqual(receivedLines, expectedLines, 'CLI output different than expectations!'); + t.is(exitCode, shouldPass ? 0 : 1, 'CLI exited with the wrong exit code!'); +}); + +export const cliPasses = _verifyCli(true); +export const cliFails = _verifyCli(false); diff --git a/test/_utils.js b/test/_utils.js deleted file mode 100644 index c87b122f..00000000 --- a/test/_utils.js +++ /dev/null @@ -1,58 +0,0 @@ -import esmock from 'esmock'; -import sinon from 'sinon'; -import {execa} from 'execa'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; - -const makeExecaStub = commands => { - const stub = sinon.stub(); - - for (const result of commands) { - const [command, ...commandArgs] = result.command.split(' '); - - // Command passes if the exit code is 0, or if there's no exit code and no stderr. - const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); - - if (passes) { - stub.withArgs(command, commandArgs).resolves(result); - } else { - stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message - } - } - - return stub; -}; - -export const _stubExeca = source => async commands => { - const execaStub = makeExecaStub(commands); - - return esmock(source, {}, { - execa: { - execa: async (...args) => execaStub.resolves(execa(...args))(...args), - }, - }); -}; - -export const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -export const assertTaskFailed = (t, taskTitle) => { - const task = SilentRenderer.tasks.find(task => task.title === taskTitle); - t.true(task.hasFailed(), `'${taskTitle}' did not fail!`); -}; - -export const assertTaskDisabled = (t, taskTitle) => { - const task = SilentRenderer.tasks.find(task => task.title === taskTitle); - t.true(!task.isEnabled(), `'${taskTitle}' was enabled!`); -}; - -export const assertTaskDoesntExist = (t, taskTitle) => { - t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `'${taskTitle}' exists!`); -}; - -export const runIfExists = async (func, ...args) => { - if (typeof func === 'function') { - await func(...args); - } -}; diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 00000000..c898fd67 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,44 @@ +import path from 'node:path'; +import test from 'ava'; +import {npPkg, npRootDir as rootDir} from '../source/util.js'; +import {cliPasses} from './_helpers/verify-cli.js'; + +const cli = path.resolve(rootDir, 'source', 'cli-implementation.js'); + +test('flags: --help', cliPasses, cli, '--help', [ + '', + 'A better `npm publish`', + '', + 'Usage', + '$ np ', + '', + 'Version can be:', + 'patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3', + '', + 'Options', + '--any-branch Allow publishing from any branch', + '--branch Name of the release branch (default: main | master)', + '--no-cleanup Skips cleanup of node_modules', + '--no-tests Skips tests', + '--yolo Skips cleanup and testing', + '--no-publish Skips publishing', + '--preview Show tasks without actually executing them', + '--tag Publish under a given dist-tag', + '--no-yarn Don\'t use Yarn', + '--contents Subdirectory to publish', + '--no-release-draft Skips opening a GitHub release draft', + '--release-draft-only Only opens a GitHub release draft for the latest published version', + '--test-script Name of npm run script to run tests before publishing (default: test)', + '--no-2fa Don\'t enable 2FA on new packages (not recommended)', + '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', + '', + 'Examples', + '$ np', + '$ np patch', + '$ np 1.0.2', + '$ np 1.0.2-beta.3 --tag=beta', + '$ np 1.0.2-beta.3 --tag=beta --contents=dist', + '', +]); + +test('flags: --version', cliPasses, cli, '--version', [npPkg.version]); diff --git a/test/config.js b/test/config.js index de56e1d9..5f2f6bd8 100644 --- a/test/config.js +++ b/test/config.js @@ -1,6 +1,5 @@ import path from 'node:path'; import test from 'ava'; -import sinon from 'sinon'; import esmock from 'esmock'; const testedModulePath = '../source/config.js'; @@ -8,13 +7,13 @@ const testedModulePath = '../source/config.js'; const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); -const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { +const getConfigsWhenGlobalBinaryIsUsed = async homedir => { const pathsPkgDir = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); const promises = pathsPkgDir.map(async pathPkgDir => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': true, - 'node:os': {homedir: homedirStub}, + 'node:os': {homedir: () => homedir}, }); return getConfig(pathPkgDir); }); @@ -37,8 +36,7 @@ const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { }; const useGlobalBinary = test.macro(async (t, homedir, source) => { - const homedirStub = sinon.stub().returns(getFixture(homedir)); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); + const configs = await getConfigsWhenGlobalBinaryIsUsed(getFixture(homedir)); for (const config of configs) { t.deepEqual(config, {source}); diff --git a/test/fixtures/files/files-and-npmignore/source/index.test-d.ts b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts index 448777bb..cd87de16 100644 --- a/test/fixtures/files/files-and-npmignore/source/index.test-d.ts +++ b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import {foo, bar} from '.'; +import {foo, bar} from './index.js'; expectType(foo()); expectType(bar()); diff --git a/test/fixtures/files/gitignore/.gitignore b/test/fixtures/files/gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/gitignore/gitignore b/test/fixtures/files/gitignore/gitignore deleted file mode 100644 index a01644f5..00000000 --- a/test/fixtures/files/gitignore/gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# This file is renamed to `.gitignore` in the test -# This is not named `.gitignore` to allow `dist/` to be committed -dist diff --git a/test/fixtures/files/gitignore/index.test-d.ts b/test/fixtures/files/gitignore/index.test-d.ts index 650c167e..85f60f8e 100644 --- a/test/fixtures/files/gitignore/index.test-d.ts +++ b/test/fixtures/files/gitignore/index.test-d.ts @@ -1,4 +1,4 @@ import {expectType} from 'tsd'; -import foo from '.'; +import foo from './index.js'; expectType(foo()); diff --git a/test/fixtures/files/npmignore-and-gitignore/.gitignore b/test/fixtures/files/npmignore-and-gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/npmignore-and-gitignore/gitignore b/test/fixtures/files/npmignore-and-gitignore/gitignore deleted file mode 100644 index a01644f5..00000000 --- a/test/fixtures/files/npmignore-and-gitignore/gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# This file is renamed to `.gitignore` in the test -# This is not named `.gitignore` to allow `dist/` to be committed -dist diff --git a/test/fixtures/files/npmignore-and-gitignore/script/build.js b/test/fixtures/files/npmignore-and-gitignore/script/build.js index 8a2c0921..94c94ba5 100644 --- a/test/fixtures/files/npmignore-and-gitignore/script/build.js +++ b/test/fixtures/files/npmignore-and-gitignore/script/build.js @@ -1,2 +1 @@ -/* eslint-disable unicorn/no-empty-file */ // ... yada yada yada diff --git a/test/fixtures/files/npmignore/index.test-d.ts b/test/fixtures/files/npmignore/index.test-d.ts index 650c167e..85f60f8e 100644 --- a/test/fixtures/files/npmignore/index.test-d.ts +++ b/test/fixtures/files/npmignore/index.test-d.ts @@ -1,4 +1,4 @@ import {expectType} from 'tsd'; -import foo from '.'; +import foo from './index.js'; expectType(foo()); diff --git a/test/git-tasks.js b/test/git-tasks.js deleted file mode 100644 index c39963d3..00000000 --- a/test/git-tasks.js +++ /dev/null @@ -1,204 +0,0 @@ -import test from 'ava'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; -import { - _stubExeca, - run, - assertTaskFailed, - assertTaskDoesntExist, -} from './_utils.js'; - -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/git-tasks.js'); - -test.afterEach(() => { - SilentRenderer.clearTasks(); -}); - -test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca([{ - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, - ); - - assertTaskFailed(t, 'Check current branch'); -}); - -test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca([{ - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }]); - - await t.throwsAsync( - run(gitTasks({branch: 'release'})), - {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, - ); - - assertTaskFailed(t, 'Check current branch'); -}); - -test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '0', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({anyBranch: true})), - ); - - assertTaskDoesntExist(t, 'Check current branch'); -}); - -test.serial('should fail when local working tree modified', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: 'M source/git-tasks.js', - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Unclean working tree. Commit or stash changes first.'}, - ); - - assertTaskFailed(t, 'Check local working tree'); -}); - -test.serial('should not fail when no remote set up', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - stderr: 'fatal: no upstream configured for branch \'master\'', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); -}); - -test.serial('should fail when remote history differs and changes are fetched', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '1', // Has unpulled changes - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Remote history differs. Please pull changes.'}, - ); - - assertTaskFailed(t, 'Check remote history'); -}); - -test.serial('should fail when remote has unfetched changes', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, - ); - - assertTaskFailed(t, 'Check remote history'); -}); - -test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '0', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); -}); diff --git a/test/git-util/check-if-file-git-ignored.js b/test/git-util/check-if-file-git-ignored.js new file mode 100644 index 00000000..1c5ba52b --- /dev/null +++ b/test/git-util/check-if-file-git-ignored.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import {temporaryDirectory} from 'tempy'; +import {checkIfFileGitIgnored} from '../../source/git-util.js'; + +test('returns true for ignored files', async t => { + t.true(await checkIfFileGitIgnored('yarn.lock')); +}); + +test('returns false for non-ignored files', async t => { + t.false(await checkIfFileGitIgnored('package.json')); +}); + +test('errors if path is outside of repo', async t => { + const temporary = temporaryDirectory(); + + await t.throwsAsync( + checkIfFileGitIgnored(`${temporary}/file.js`), + {message: /fatal:/}, + ); +}); diff --git a/test/git-util/commit-log-from-revision.js b/test/git-util/commit-log-from-revision.js new file mode 100644 index 00000000..bf0a780b --- /dev/null +++ b/test/git-util/commit-log-from-revision.js @@ -0,0 +1,32 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns single commit', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const {sha, commitMessage} = await t.context.commitNewFile(); + + t.is(await commitLogFromRevision('v0.0.0'), `${commitMessage} ${sha}`); +}); + +test('returns multiple commits, from newest to oldest', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const commit1 = await t.context.commitNewFile(); + const commit2 = await t.context.commitNewFile(); + const commit3 = await t.context.commitNewFile(); + + const commitLog = stripIndent` + ${commit3.commitMessage} ${commit3.sha} + ${commit2.commitMessage} ${commit2.sha} + ${commit1.commitMessage} ${commit1.sha} + `; + + t.is(await commitLogFromRevision('v0.0.0'), commitLog); +}); diff --git a/test/git-util/default-branch.js b/test/git-util/default-branch.js new file mode 100644 index 00000000..07fc1c6a --- /dev/null +++ b/test/git-util/default-branch.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('main', createFixture, async ({$$}) => { + await $$`git checkout -B main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'main'); +}); + +test('master', createFixture, async ({$$}) => { + await $$`git checkout -B master`; + await $$`git update-ref -d refs/heads/main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'master'); +}); + +test('gh-pages', createFixture, async ({$$}) => { + await $$`git checkout -B gh-pages`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'gh-pages'); +}); + +test('fails', createFixture, async ({$$}) => { + await $$`git checkout -B unicorn`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + await t.throwsAsync( + defaultBranch(), + {message: 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'}, + ); +}); diff --git a/test/git-util/delete-tag.js b/test/git-util/delete-tag.js new file mode 100644 index 00000000..15c54016 --- /dev/null +++ b/test/git-util/delete-tag.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('deletes given tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v1.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.is(tags, 'v0.0.0'); +}); + +test('deletes given tag from a large list', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; + await $$`git tag v2.0.0`; + await $$`git tag v3.0.0`; + await $$`git tag v4.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v2.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.deepEqual( + tags.split('\n'), + ['v0.0.0', 'v1.0.0', 'v3.0.0', 'v4.0.0'], + ); +}); + +test('throws if tag not found', createFixture, async () => { + // +}, async ({t, testedModule: {deleteTag}}) => { + await t.throwsAsync( + deleteTag('v1.0.0'), + {message: /error: tag 'v1\.0\.0' not found\./}, + ); +}); diff --git a/test/git-util/get-current-branch.js b/test/git-util/get-current-branch.js new file mode 100644 index 00000000..14623264 --- /dev/null +++ b/test/git-util/get-current-branch.js @@ -0,0 +1,12 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns current branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {getCurrentBranch}}) => { + const currentBranch = await getCurrentBranch(); + t.is(currentBranch, 'unicorn'); +}); diff --git a/test/git-util/has-upstream.js b/test/git-util/has-upstream.js new file mode 100644 index 00000000..43e67aec --- /dev/null +++ b/test/git-util/has-upstream.js @@ -0,0 +1,11 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no upstream', createFixture, async () => { + // +}, async ({t, testedModule: {hasUpstream}}) => { + t.false(await hasUpstream()); +}); diff --git a/test/git-util/is-head-detached.js b/test/git-util/is-head-detached.js new file mode 100644 index 00000000..85de14a2 --- /dev/null +++ b/test/git-util/is-head-detached.js @@ -0,0 +1,18 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('not detached', createFixture, async () => { + // +}, async ({t, testedModule: {isHeadDetached}}) => { + t.false(await isHeadDetached()); +}); + +test('detached', createFixture, async ({$$}) => { + const {stdout: firstCommitSha} = await $$`git rev-list --max-parents=0 HEAD`; + await $$`git checkout ${firstCommitSha}`; +}, async ({t, testedModule: {isHeadDetached}}) => { + t.true(await isHeadDetached()); +}); diff --git a/test/git-util/latest-tag-or-first-commit.js b/test/git-util/latest-tag-or-first-commit.js new file mode 100644 index 00000000..526938c4 --- /dev/null +++ b/test/git-util/latest-tag-or-first-commit.js @@ -0,0 +1,33 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +// From https://stackoverflow.com/a/3357357/10292952 +const getCommitMessage = async ($$, sha) => $$`git log --format=%B -n 1 ${sha}`; + +test('one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v1.0.0'); +}); + +test('no tags (fallback)', createFixture, async () => { + // +}, async ({t, testedModule: {latestTagOrFirstCommit}, $$}) => { + const result = await latestTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + + t.is(firstCommitMessage.trim(), '"init1"'); +}); diff --git a/test/git-util/latest-tag.js b/test/git-util/latest-tag.js new file mode 100644 index 00000000..9c876f85 --- /dev/null +++ b/test/git-util/latest-tag.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns latest tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v0.0.0'); +}); + +test('returns latest tag - multiple set', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v4.0.0'); +}); diff --git a/test/git-util/new-files-since-last-release.js b/test/git-util/new-files-since-last-release.js new file mode 100644 index 00000000..01ceb737 --- /dev/null +++ b/test/git-util/new-files-since-last-release.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns files added since latest tag', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual( + newFiles.sort(), + ['new', 'index.js'].sort(), + ); +}); + +test('no files', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual(newFiles, []); +}); + +test('uses ignoreWalker', createFixture, async ({t}) => { + await t.context.createFile('index.js'); + await t.context.createFile('package.json'); + await t.context.createFile('package-lock.json'); + await t.context.createFile('.gitignore', 'package-lock.json\n.git'); // ignoreWalker doesn't ignore `.git`: npm/ignore-walk#2 +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual( + newFiles.sort(), + ['index.js', 'package.json', '.gitignore'].sort(), + ); +}); diff --git a/test/git-util/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js new file mode 100644 index 00000000..53287bbd --- /dev/null +++ b/test/git-util/previous-tag-or-first-commit.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no tags', createFixture, () => { + // +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, undefined); +}); + +test('one tag - fallback to first commit', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + const commitMessage = await t.context.getCommitMessage(result); + + t.is(commitMessage, t.context.firstCommitMessage); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('multiple tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v3.0.0'); +}); + +test.todo('test fallback case'); diff --git a/test/git-util/push-graceful.js b/test/git-util/push-graceful.js new file mode 100644 index 00000000..97b6c14a --- /dev/null +++ b/test/git-util/push-graceful.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('succeeds', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 0, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.notThrowsAsync( + pushGraceful(), + ); +}); + +test('fails w/ remote on GitHub and bad branch permission', createFixture, [ + { + command: 'git push --follow-tags', + stderr: 'GH006', + }, + { + command: 'git push --tags', + exitCode: 0, + }, +], async ({t, testedModule: {pushGraceful}}) => { + const {pushed, reason} = await pushGraceful(true); + + t.is(pushed, 'tags'); + t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); +}); + +test('throws', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 1, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.throwsAsync( + pushGraceful(false), + ); +}); + diff --git a/test/git-util/read-file-from-last-release.js b/test/git-util/read-file-from-last-release.js new file mode 100644 index 00000000..7d945f63 --- /dev/null +++ b/test/git-util/read-file-from-last-release.js @@ -0,0 +1,43 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns content of a given file', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn-1'); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn-2'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + const file = await readFileFromLastRelease('unicorn.txt'); + t.is(file, 'unicorn-1'); +}); + +test('fails if file not in previous release', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: path '[^']*' exists on disk, but not in 'v0\.0\.0'/}, + ); +}); + +test('no previous release', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: No names found, cannot describe anything./}, + ); +}); + +// These errors could probably be handled in 'readFileFromLastRelease' diff --git a/test/git-util/remove-last-commit.js b/test/git-util/remove-last-commit.js new file mode 100644 index 00000000..de7d794f --- /dev/null +++ b/test/git-util/remove-last-commit.js @@ -0,0 +1,19 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('removes latest commit', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {removeLastCommit}, $$}) => { + const {stdout: commitsBefore} = await $$`git log --pretty="%s"`; + t.true(commitsBefore.includes('"added"')); + + await removeLastCommit(); + + const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; + t.false(commitsAfter.includes('"added"')); +}); diff --git a/test/git-util/root.js b/test/git-util/root.js new file mode 100644 index 00000000..6689df70 --- /dev/null +++ b/test/git-util/root.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; +import {npRootDir} from '../../source/util.js'; +import {root} from '../../source/git-util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns np root dir', async t => { + t.is(await root(), npRootDir); +}); + +test('returns root dir of temp dir', createFixture, () => { + // +}, async ({t, testedModule: git, temporaryDir}) => { + t.is(await git.root(), temporaryDir); +}); diff --git a/test/git-util/verify-current-branch-is-release-branch.js b/test/git-util/verify-current-branch-is-release-branch.js new file mode 100644 index 00000000..fa6165b6 --- /dev/null +++ b/test/git-util/verify-current-branch-is-release-branch.js @@ -0,0 +1,22 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.notThrowsAsync( + verifyCurrentBranchIsReleaseBranch('unicorn'), + ); +}); + +test('not on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.throwsAsync( + verifyCurrentBranchIsReleaseBranch('main'), + {message: 'Not on `main` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); +}); diff --git a/test/git-util/verify-recent-git-version.js b/test/git-util/verify-recent-git-version.js new file mode 100644 index 00000000..2a45601e --- /dev/null +++ b/test/git-util/verify-recent-git-version.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.12.0', // One higher than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.notThrowsAsync( + verifyRecentGitVersion(), + ); +}); + +test('not satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.10.0', // One lower than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.throwsAsync( + verifyRecentGitVersion(), + {message: '`np` requires git >=2.11.0'}, + ); +}); diff --git a/test/git-util/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js new file mode 100644 index 00000000..14fd8ef1 --- /dev/null +++ b/test/git-util/verify-remote-history-is-clean.js @@ -0,0 +1,74 @@ +import test from 'ava'; +import {_createFixture as _createStubFixture} from '../_helpers/stub-execa.js'; +import {_createFixture as _createIntegrationFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createStubFixture = _createStubFixture('../../source/git-util.js', import.meta.url); + +/** @type {ReturnType>} */ +const createIntegrationFixture = _createIntegrationFixture('../../source/git-util.js'); + +test('unfetched changes', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.throwsAsync( + verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); +}); + +test('unclean remote history', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.throwsAsync( + verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please pull changes.'}, + ); +}); + +test('clean fetched remote history', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', // No changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.notThrowsAsync( + verifyRemoteHistoryIsClean(), + ); +}); + +test('no remote', createIntegrationFixture, async () => { + // +}, async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + const result = await t.notThrowsAsync( + verifyRemoteHistoryIsClean(), + ); + + t.is(result, undefined); +}); diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js new file mode 100644 index 00000000..fce75912 --- /dev/null +++ b/test/git-util/verify-remote-is-valid.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import {_createFixture as _createStubFixture} from '../_helpers/stub-execa.js'; +import {_createFixture as _createIntegrationFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createStubFixture = _createStubFixture('../../source/git-util.js', import.meta.url); + +/** @type {ReturnType>} */ +const createIntegrationFixture = _createIntegrationFixture('../../source/git-util.js'); + +test('has remote', createStubFixture, [{ + command: 'git ls-remote origin HEAD', + exitCode: 0, +}], async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.notThrowsAsync( + verifyRemoteIsValid(), + ); +}); + +test('no remote', createIntegrationFixture, async () => { + // +}, async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.throwsAsync( + verifyRemoteIsValid(), + {message: /^Git fatal error:/m}, + ); +}); diff --git a/test/git-util/verify-tag-does-not-exist-on-remote.js b/test/git-util/verify-tag-does-not-exist-on-remote.js new file mode 100644 index 00000000..216c9d02 --- /dev/null +++ b/test/git-util/verify-tag-does-not-exist-on-remote.js @@ -0,0 +1,26 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + stdout: '123456789', // Some hash +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.throwsAsync( + verifyTagDoesNotExistOnRemote('v0.0.0'), + {message: 'Git tag `v0.0.0` already exists.'}, + ); +}); + +test('does not exist', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + exitCode: 1, + stderr: '', + stdout: '', +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.notThrowsAsync( + verifyTagDoesNotExistOnRemote('v0.0.0'), + ); +}); diff --git a/test/git-util/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js new file mode 100644 index 00000000..d1fc0774 --- /dev/null +++ b/test/git-util/verify-working-tree-is-clean.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('clean', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.notThrowsAsync( + verifyWorkingTreeIsClean(), + ); +}); + +test('not clean', createFixture, async ({t}) => { + await t.context.createFile('index.js'); +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.throwsAsync( + verifyWorkingTreeIsClean(), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); +}); diff --git a/test/hyperlinks.js b/test/hyperlinks.js deleted file mode 100644 index c5879174..00000000 --- a/test/hyperlinks.js +++ /dev/null @@ -1,56 +0,0 @@ -import test from 'ava'; -import sinon from 'sinon'; -import terminalLink from 'terminal-link'; -import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util.js'; - -const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; -const MOCK_COMMIT_HASH = '5063f8a'; -const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; - -const sandbox = sinon.createSandbox(); - -test.afterEach(() => { - sandbox.restore(); -}); - -const mockTerminalLinkUnsupported = () => - sandbox.stub(terminalLink, 'isSupported').value(false); - -test('linkifyIssues correctly links issues', t => { - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); -}); - -test('linkifyIssues returns raw message if url is not provided', t => { - const message = 'Commit message - fixes #5'; - t.is(linkifyIssues(undefined, message), message); -}); - -test.serial('linkifyIssues returns raw message if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - const message = 'Commit message - fixes #6'; - t.is(linkifyIssues(MOCK_REPO_URL, message), message); -}); - -test('linkifyCommit correctly links commits', t => { - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); -}); - -test('linkifyCommit returns raw commit hash if url is not provided', t => { - t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test.serial('linkifyCommit returns raw commit hash if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test('linkifyCommitRange returns raw commitRange if url is not provided', t => { - t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); - -test.serial('linkifyCommitRange returns raw commitRange if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); diff --git a/test/index.js b/test/index.js index 03a0660c..bf3ac759 100644 --- a/test/index.js +++ b/test/index.js @@ -16,18 +16,18 @@ const defaultOptions = { renderer: 'silent', }; -const npPkg = await util.readPkg(); +const npPkgResult = await util.readPkg(); const npFails = test.macro(async (t, inputs, message) => { await t.throwsAsync( - Promise.all(inputs.map(input => np(input, defaultOptions, npPkg))), + Promise.all(inputs.map(input => np(input, defaultOptions, npPkgResult))), {message}, ); }); test('version is invalid', npFails, ['foo', '4.x.3'], - 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.', + /New version (?:foo|4\.x\.3) should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version\./, ); test('version is pre-release', npFails, @@ -37,12 +37,13 @@ test('version is pre-release', npFails, test('errors on too low version', npFails, ['1.0.0', '1.0.0-beta'], - /New version `1\.0\.0(?:-beta)?` should be higher than current version `\d+\.\d+\.\d+`/, + /New version 1\.0\.0(?:-beta)? should be higher than current version \d+\.\d+\.\d+/, ); test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); + /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, @@ -62,7 +63,7 @@ test('skip enabling 2FA if the package exists', async t => { isAvailable: false, isUnknown: false, }, - }, npPkg)); + }, npPkgResult)); t.true(enable2faStub.notCalled); }); @@ -70,6 +71,7 @@ test('skip enabling 2FA if the package exists', async t => { test('skip enabling 2FA if the `2fa` option is false', async t => { const enable2faStub = sinon.stub(); + /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, @@ -90,7 +92,7 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { isUnknown: false, }, '2fa': false, - }, npPkg)); + }, npPkgResult)); t.true(enable2faStub.notCalled); }); diff --git a/test/integration.js b/test/integration.js deleted file mode 100644 index fab34722..00000000 --- a/test/integration.js +++ /dev/null @@ -1,105 +0,0 @@ -import path from 'node:path'; -import fs from 'fs-extra'; -import test from 'ava'; -import esmock from 'esmock'; -import {$, execa} from 'execa'; -import {temporaryDirectoryTask} from 'tempy'; -import {writePackage} from 'write-pkg'; - -const createEmptyGitRepo = async ($$, temporaryDir) => { - await $$`git init`; - - // `git tag` needs an initial commit - await fs.createFile(path.resolve(temporaryDir, 'temp')); - await $$`git add temp`; - await $$`git commit -m "init1"`; - await $$`git rm temp`; - await $$`git commit -m "init2"`; -}; - -const createIntegrationTest = async (t, assertions) => { - await temporaryDirectoryTask(async temporaryDir => { - const $$ = $({cwd: temporaryDir}); - - await createEmptyGitRepo($$, temporaryDir); - - t.context.createFile = async file => fs.createFile(path.resolve(temporaryDir, file)); - await assertions($$, temporaryDir); - }); -}; - -test('main', async t => { - await createIntegrationTest(t, async $$ => { - await t.context.createFile('testFile'); - - const {stdout} = await $$`git status -u`; - - t.true( - stdout.includes('Untracked files') && stdout.includes('testFile'), - 'File wasn\'t created properly!', - ); - }); -}); - -const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { - await createIntegrationTest(t, async ($$, temporaryDir) => { - /** @type {import('../source/util.js')} */ - const util = await esmock('../source/util.js', {}, { - 'node:process': {cwd: () => temporaryDir}, - execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, - }); - - await commands(t, $$, temporaryDir); - - await writePackage(temporaryDir, { - name: 'foo', - version: '0.0.0', - ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, - }); - - t.deepEqual( - await util.getNewFiles(temporaryDir), - {unpublished, firstTime}, - ); - }); -}); - -test('files to package with tags added', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile('new'); - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, {unpublished: ['new'], firstTime: ['index.js']}); - -test('file `new` to package without tags added', createNewFilesFixture, ['index.js'], async t => { - await t.context.createFile('new'); - await t.context.createFile('index.js'); -}, {unpublished: ['new'], firstTime: ['index.js', 'package.json']}); - -(() => { // Wrapper to have constants with macro - const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); - const filePath1 = path.join(longPath, 'file1'); - const filePath2 = path.join(longPath, 'file2'); - - test('files with long pathnames added', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile(filePath1); - await t.context.createFile(filePath2); - await $$`git add -A`; - await $$`git commit -m "added"`; - }, {unpublished: [filePath1, filePath2], firstTime: []}); -})(); - -test('no new files added', createNewFilesFixture, [], async (_t, $$) => { - await $$`git tag v0.0.0`; -}, {unpublished: [], firstTime: []}); - -test('ignores .git and .github files', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile('.github/workflows/main.yml'); - await t.context.createFile('.github/pull_request_template'); - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, {unpublished: [], firstTime: ['index.js']}); diff --git a/test/npm/enable-2fa.js b/test/npm/enable-2fa.js new file mode 100644 index 00000000..59fa5203 --- /dev/null +++ b/test/npm/enable-2fa.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/npm/enable-2fa.js', import.meta.url); + +const npmVersionFixtures = [ + {version: '8.0.0', accessArgs: ['access', '2fa-required']}, + {version: '9.0.0', accessArgs: ['access', 'set', 'mfa=publish']}, +]; + +for (const {version, accessArgs} of npmVersionFixtures) { + const npmVersionCommand = [{ + command: 'npm --version', + stdout: version, + }]; + + test(`npm v${version} - no options`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np'), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {confirm: true}), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {otp: '123456'}), + [...accessArgs, 'np', '--otp', '123456'], + ); + }, + ); +} + diff --git a/test/npm/handle-npm-error.js b/test/npm/handle-npm-error.js new file mode 100644 index 00000000..61b6959a --- /dev/null +++ b/test/npm/handle-npm-error.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import handleNpmError from '../../source/npm/handle-npm-error.js'; + +const makeError = ({code, stdout, stderr}) => ({ + code, + stdout: stdout ?? '', + stderr: stderr ?? '', +}); + +test('error code 402 - privately publish scoped package', t => { + t.throws( + () => handleNpmError(makeError({code: 402})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); + + t.throws( + () => handleNpmError(makeError({stderr: 'npm ERR! 402 Payment Required'})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); +}); diff --git a/test/npm/publish.js b/test/npm/publish.js new file mode 100644 index 00000000..316747fa --- /dev/null +++ b/test/npm/publish.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import {getPackagePublishArguments} from '../../source/npm/publish.js'; + +test('no options set', t => { + t.deepEqual( + getPackagePublishArguments({}), + ['publish'], + ); +}); + +test('options.contents', t => { + t.deepEqual( + getPackagePublishArguments({contents: 'dist'}), + ['publish', 'dist'], + ); +}); + +test('options.tag', t => { + t.deepEqual( + getPackagePublishArguments({tag: 'beta'}), + ['publish', '--tag', 'beta'], + ); +}); + +test('options.otp', t => { + t.deepEqual( + getPackagePublishArguments({otp: '123456'}), + ['publish', '--otp', '123456'], + ); +}); + +test('options.publishScoped', t => { + t.deepEqual( + getPackagePublishArguments({publishScoped: true}), + ['publish', '--access', 'public'], + ); +}); diff --git a/test/npm/util/check-connection.js b/test/npm/util/check-connection.js new file mode 100644 index 00000000..39e32362 --- /dev/null +++ b/test/npm/util/check-connection.js @@ -0,0 +1,36 @@ +import {setTimeout} from 'node:timers/promises'; +import test from 'ava'; +import esmock from 'esmock'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('success', createFixture, [{ + command: 'npm ping', + exitCode: 0, +}], async ({t, testedModule: npm}) => { + t.true(await npm.checkConnection()); +}); + +test('fail', createFixture, [{ + command: 'npm ping', + exitCode: 1, +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.checkConnection(), + {message: 'Connection to npm registry failed'}, + ); +}); + +test('timeout', async t => { + t.timeout(16_000); + const npm = await esmock('../../../source/npm/util.js', {}, { + execa: {execa: async () => setTimeout(16_000, {})}, + }); + + await t.throwsAsync( + npm.checkConnection(), + {message: 'Connection to npm registry timed out'}, + ); +}); diff --git a/test/npm/util/check-ignore-strategy.js b/test/npm/util/check-ignore-strategy.js new file mode 100644 index 00000000..1617eb85 --- /dev/null +++ b/test/npm/util/check-ignore-strategy.js @@ -0,0 +1,35 @@ +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import stripAnsi from 'strip-ansi'; +import {oneLine} from 'common-tags'; + +const checkIgnoreStrategy = test.macro(async (t, {fixture = '', files, expected = ''} = {}) => { + let output = ''; + + /** @type {import('../../../source/npm/util.js')} */ + const {checkIgnoreStrategy} = await esmock('../../../source/npm/util.js', { + import: {console: {log: (...args) => output = args.join('')}}, // eslint-disable-line no-return-assign + }); + + const fixtureDir = path.resolve('test/fixtures/files', fixture); + const pkg = files ? {files} : {}; + + await checkIgnoreStrategy(pkg, fixtureDir); + + output = stripAnsi(output).trim(); + t.is(output, expected); +}); + +const ignoreStrategyMessage = oneLine` + Warning: No files field specified in package.json nor is a .npmignore file present. + Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. +`; + +test('no files, no .npmignore', checkIgnoreStrategy, {fixture: 'main', expected: ignoreStrategyMessage}); + +test('no files w/ .npmignore', checkIgnoreStrategy, {fixture: 'npmignore', expected: ''}); + +test('files, no .npmignore', checkIgnoreStrategy, {fixture: 'main', files: ['index.js'], expected: ''}); + +test('files w/ .npmignore', checkIgnoreStrategy, {fixture: 'npmignore', files: ['index.js'], expected: ''}); diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js new file mode 100644 index 00000000..93fb5c06 --- /dev/null +++ b/test/npm/util/collaborators.js @@ -0,0 +1,93 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../../_helpers/stub-execa.js'; +import * as npm from '../../../source/npm/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('pkg.name not a string', async t => { + await t.throwsAsync( + npm.collaborators({name: 1}), + {message: 'Expected argument to be of type `string` but received type `number`'}, + ); +}); + +const npmVersionFixtures = [ + {version: '8.0.0', accessCommand: 'npm access ls-collaborators np'}, + {version: '9.0.0', accessCommand: 'npm access list collaborators np --json'}, +]; + +for (const {version, accessCommand} of npmVersionFixtures) { + const npmVersionCommand = { + command: 'npm --version', + stdout: version, + }; + + const collaboratorsStdout = stripIndent` + { + "sindresorhus": "read-write", + "samverschueren": "read-write", + "itaisteinherz": "read-write" + } + `; + + test(`npm v${version}`, createFixture, [ + npmVersionCommand, + { + command: accessCommand, + stdout: collaboratorsStdout, + }, + ], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({name: 'np'}), + collaboratorsStdout, + ); + }); + + test(`npm v${version} - external registry`, createFixture, [ + npmVersionCommand, + { + command: `${accessCommand} --registry http://my-internal-registry.local`, + stdout: collaboratorsStdout, + }, + ], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({ + name: 'np', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + collaboratorsStdout, + ); + }); + + test(`npm v${version} - non-existent`, createFixture, [ + npmVersionCommand, + { + command: version === '8.0.0' + ? 'npm access ls-collaborators non-existent' + : 'npm access list collaborators non-existent --json', + stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found', + }, + ], async ({t, testedModule: {collaborators}}) => { + t.is( + await collaborators({name: 'non-existent'}), + false, + ); + }); + + test(`npm v${version} - error`, createFixture, [ + npmVersionCommand, + { + command: version === '8.0.0' + ? 'npm access ls-collaborators @private/pkg' + : 'npm access list collaborators @private/pkg --json', + stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', + }, + ], async ({t, testedModule: {collaborators}}) => { + const {stderr} = await t.throwsAsync(collaborators({name: '@private/pkg'})); + t.is(stderr, 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden'); + }); +} diff --git a/test/npm/util/get-registry-url.js b/test/npm/util/get-registry-url.js new file mode 100644 index 00000000..fdfd47ea --- /dev/null +++ b/test/npm/util/get-registry-url.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('npm', createFixture, [{ + command: 'npm config get registry', + stdout: 'https://registry.npmjs.org/', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('npm', {}), + 'https://registry.npmjs.org/', + ); +}); + +test('yarn', createFixture, [{ + command: 'yarn config get registry', + stdout: 'https://registry.yarnpkg.com', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('yarn', {}), + 'https://registry.yarnpkg.com', + ); +}); + +test('external', createFixture, [{ + command: 'npm config get registry --registry http://my-internal-registry.local', + stdout: 'http://my-internal-registry.local', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('npm', { + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + 'http://my-internal-registry.local', + ); +}); diff --git a/test/npm/util/is-external-registry.js b/test/npm/util/is-external-registry.js new file mode 100644 index 00000000..c449cc66 --- /dev/null +++ b/test/npm/util/is-external-registry.js @@ -0,0 +1,10 @@ +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +test('main', t => { + t.true(npm.isExternalRegistry({publishConfig: {registry: 'https://my-internal-registry.local'}})); + + t.false(npm.isExternalRegistry({name: 'foo'})); + t.false(npm.isExternalRegistry({publishConfig: {registry: true}})); + t.false(npm.isExternalRegistry({publishConfig: 'not an object'})); +}); diff --git a/test/npm/util/is-package-name-available.js b/test/npm/util/is-package-name-available.js new file mode 100644 index 00000000..084f45d6 --- /dev/null +++ b/test/npm/util/is-package-name-available.js @@ -0,0 +1,42 @@ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; + +const externalRegistry = 'http://my-internal-registry.local'; + +const createFixture = test.macro(async (t, {name = 'foo', npmNameStub, expected, isExternalRegistry = false}) => { + /** @type {import('../../../source/npm/util.js')} */ + const npm = await esmock('../../../source/npm/util.js', { + 'npm-name': npmNameStub, + }); + + const pkg = isExternalRegistry + ? {name, publishConfig: {registry: externalRegistry}} + : {name}; + + const availability = await npm.isPackageNameAvailable(pkg); + t.like(availability, expected); +}); + +test('available', createFixture, { + npmNameStub: sinon.stub().resolves(true), + expected: {isAvailable: true, isUnknown: false}, +}); + +test('unavailable', createFixture, { + npmNameStub: sinon.stub().resolves(false), + expected: {isAvailable: false, isUnknown: false}, +}); + +test('bad package name', createFixture, { + name: '_foo', + npmNameStub: sinon.stub().rejects('Invalid package name: _foo\n- name cannot start with an underscore'), + expected: {isAvailable: false, isUnknown: true}, +}); + +test('external registry', createFixture, { + name: 'external-foo', + isExternalRegistry: true, + npmNameStub: async (name, {registryUrl}) => name === 'external-foo' && registryUrl === externalRegistry, + expected: {isAvailable: true, isUnknown: false}, +}); diff --git a/test/packed-files.js b/test/npm/util/packed-files.js similarity index 81% rename from test/packed-files.js rename to test/npm/util/packed-files.js index b2a44aad..a3034fb9 100644 --- a/test/packed-files.js +++ b/test/npm/util/packed-files.js @@ -1,8 +1,7 @@ import path from 'node:path'; import test from 'ava'; -import {renameFile} from 'move-file'; -import {getFilesToBePacked} from '../source/npm/util.js'; -import {runIfExists} from './_utils.js'; +import {getFilesToBePacked} from '../../../source/npm/util.js'; +import {runIfExists} from '../../_helpers/util.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); @@ -53,24 +52,15 @@ test('package.json files field and npmignore', verifyPackedFiles, 'files-and-npm 'source/index.d.ts', ]); -const renameDotGitignore = { - async before(fixtureDir) { - await renameFile('gitignore', '.gitignore', {cwd: fixtureDir}); - }, - async after(fixtureDir) { - await renameFile('.gitignore', 'gitignore', {cwd: fixtureDir}); - }, -}; - test('package.json files field and gitignore', verifyPackedFiles, 'gitignore', [ 'readme.md', 'dist/index.js', -], renameDotGitignore); +]); test('npmignore and gitignore', verifyPackedFiles, 'npmignore-and-gitignore', [ 'readme.md', 'dist/index.js', -], renameDotGitignore); +]); test('package.json main field not in files field', verifyPackedFiles, 'main', [ 'foo.js', diff --git a/test/npm/util/prerelease-tags.js b/test/npm/util/prerelease-tags.js new file mode 100644 index 00000000..a620cfd9 --- /dev/null +++ b/test/npm/util/prerelease-tags.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../../_helpers/stub-execa.js'; +import * as npm from '../../../source/npm/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('packageName not a string', async t => { + await t.throwsAsync( + npm.prereleaseTags(1), + {message: 'Expected argument to be of type `string` but received type `number`'}, + ); +}); + +test('tags: latest', createFixture, [{ + command: 'npm view --json foo dist-tags', + stdout: JSON.stringify({ + latest: '1.0.0', + }), +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('foo'), + ['next'], + ); +}); + +test('tags: latest, beta', createFixture, [{ + command: 'npm view --json foo dist-tags', + stdout: JSON.stringify({ + latest: '1.0.0', + beta: '2.0.0-beta', + }), +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('foo'), + ['beta'], + ); +}); + +test('non-existent (code 404) - should not throw', createFixture, [{ + command: 'npm view --json non-existent dist-tags', + stderr: stripIndent` + npm ERR! code E404 + npm ERR! 404 Not Found - GET https://registry.npmjs.org/non-existent - Not found + npm ERR! 404 + npm ERR! 404 'non-existent@*' is not in this registry. + npm ERR! 404 + npm ERR! 404 Note that you can also install from a + npm ERR! 404 tarball, folder, http url, or git url. + { + "error": { + "code": "E404", + "summary": "Not Found - GET https://registry.npmjs.org/non-existent - Not found", + "detail": "'non-existent@*' is not in this registry. Note that you can also install from a tarball, folder, http url, or git url." + } + } + npm ERR! A complete log of this run can be found in: + npm ERR! ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('non-existent'), + ['next'], + ); +}); + +test('bad permission (code 403) - should throw', createFixture, [{ + command: 'npm view --json @private/pkg dist-tags', + stderr: stripIndent` + npm ERR! code E403 + npm ERR! 403 403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden + npm ERR! 403 In most cases, you or one of your dependencies are requesting + npm ERR! 403 a package version that is forbidden by your security policy, or + npm ERR! 403 on a server you do not have access to. + { + "error": { + "code": "E403", + "summary": "403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden", + "detail": "In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to." + } + } + npm ERR! A complete log of this run can be found in: + npm ERR! ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + const error = await t.throwsAsync(prereleaseTags('@private/pkg')); + t.true(error.stderr?.includes('E403')); +}); diff --git a/test/npm/util/username.js b/test/npm/util/username.js new file mode 100644 index 00000000..87235890 --- /dev/null +++ b/test/npm/util/username.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('main', createFixture, [{ + command: 'npm whoami', + stdout: 'sindresorhus', +}], async ({t, testedModule: npm}) => { + t.is(await npm.username({}), 'sindresorhus'); +}); + +test('--registry flag', createFixture, [{ + command: 'npm whoami --registry http://my.io', + stdout: 'sindresorhus', +}], async ({t, testedModule: npm}) => { + t.is(await npm.username({externalRegistry: 'http://my.io'}), 'sindresorhus'); +}); + +test('fails if not logged in', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! code ENEEDAUTH', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.username({}), + {message: 'You must be logged in. Use `npm login` and try again.'}, + ); +}); + +test('fails with authentication error', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! OTP required for authentication', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.username({}), + {message: 'Authentication error. Use `npm whoami` to troubleshoot.'}, + ); +}); diff --git a/test/npm/util/verify-recent-npm-version.js b/test/npm/util/verify-recent-npm-version.js new file mode 100644 index 00000000..a5332d90 --- /dev/null +++ b/test/npm/util/verify-recent-npm-version.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.20.0', // One higher than minimum +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync( + npm.verifyRecentNpmVersion(), + ); +}); + +test('not satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.18.0', // One lower than minimum +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.verifyRecentNpmVersion(), + {message: '`np` requires npm >=7.19.0'}, + ); +}); diff --git a/test/prefix.js b/test/prefix.js deleted file mode 100644 index 7eb5ef8c..00000000 --- a/test/prefix.js +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'ava'; -import esmock from 'esmock'; -import {stripIndent} from 'common-tags'; -import {getTagVersionPrefix} from '../source/util.js'; - -test('get tag prefix', async t => { - t.is(await getTagVersionPrefix({yarn: false}), 'v'); - t.is(await getTagVersionPrefix({yarn: true}), 'v'); -}); - -test('no options passed', async t => { - await t.throwsAsync(getTagVersionPrefix(), {message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` - `}); - await t.throwsAsync(getTagVersionPrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); -}); - -test.serial('defaults to "v" when command fails', async t => { - const testedModule = await esmock('../source/util.js', { - execa: {default: Promise.reject}, - }); - - t.is(await testedModule.getTagVersionPrefix({yarn: true}), 'v'); -}); diff --git a/test/preid.js b/test/preid.js deleted file mode 100644 index b83bcd84..00000000 --- a/test/preid.js +++ /dev/null @@ -1,16 +0,0 @@ -import test from 'ava'; -import {stripIndent} from 'common-tags'; -import {getPreReleasePrefix} from '../source/util.js'; - -test('get preId postfix', async t => { - t.is(await getPreReleasePrefix({yarn: false}), ''); - t.is(await getPreReleasePrefix({yarn: true}), ''); -}); - -test('no options passed', async t => { - await t.throwsAsync(getPreReleasePrefix(), {message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` - `}); - await t.throwsAsync(getPreReleasePrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); -}); diff --git a/test/release-task-helper.js b/test/release-task-helper.js new file mode 100644 index 00000000..a20ee673 --- /dev/null +++ b/test/release-task-helper.js @@ -0,0 +1,61 @@ +import test from 'ava'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +const verifyRelease = test.macro(async (t, {oldVersion, newVersion, prefixes = {}, like}) => { + const repoUrl = 'https://github.com/sindresorhus/np'; + + /** @type {import('../source/release-task-helper.js')} */ + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: sinon.stub(), + '../source/util.js': { + getTagVersionPrefix: async () => prefixes.tag ?? 'v', + getPreReleasePrefix: async () => prefixes.preRelease ?? '', + }, + 'new-github-release-url': options_ => t.like(options_, {repoUrl, ...like}), + }); + + await releaseTaskHelper( + {version: newVersion, repoUrl, releaseNotes: sinon.stub()}, + {version: oldVersion}, + ); +}); + +// TODO: test `body` + +test('main', verifyRelease, { + oldVersion: '1.0.0', + newVersion: '1.1.0', + like: { + tag: 'v1.1.0', + isPrerelease: false, + }, +}); + +test('handles increment as new version', verifyRelease, { + oldVersion: '1.0.0', + newVersion: 'minor', + like: { + tag: 'v1.1.0', + isPrerelease: false, + }, +}); + +test('uses resolved prefix', verifyRelease, { + oldVersion: '1.0.0', + newVersion: '1.1.0', + prefixes: {tag: 'ver'}, + like: { + tag: 'ver1.1.0', + }, +}); + +test('prerelease', verifyRelease, { + oldVersion: '1.0.0', + newVersion: 'prerelease', + prefixes: {preRelease: 'beta'}, + like: { + tag: 'v1.0.1-beta.0', + isPrerelease: true, + }, +}); diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js new file mode 100644 index 00000000..89d4aef7 --- /dev/null +++ b/test/tasks/git-tasks.js @@ -0,0 +1,184 @@ +import test from 'ava'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDoesntExist} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-tasks.js', import.meta.url); + +test.afterEach(() => { + SilentRenderer.clearTasks(); +}); + +test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'release'})), + {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should not fail when current branch not master and publishing from any branch permitted', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({anyBranch: true})), + ); + + assertTaskDoesntExist(t, 'Check current branch'); +}); + +test.serial('should fail when local working tree modified', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: 'M source/git-tasks.js', + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); + + assertTaskFailed(t, 'Check local working tree'); +}); + +test.serial('should not fail when no remote set up', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for branch \'master\'', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); + +test.serial('should fail when remote history differs and changes are fetched', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('should fail when remote has unfetched changes', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); diff --git a/test/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js similarity index 53% rename from test/prerequisite-tasks.js rename to test/tasks/prerequisite-tasks.js index 9be09523..a2f8b304 100644 --- a/test/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,33 +1,25 @@ import process from 'node:process'; import test from 'ava'; -import {readPackageUp} from 'read-pkg-up'; -import Version from '../source/version.js'; -import actualPrerequisiteTasks from '../source/prerequisite-tasks.js'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; -import { - _stubExeca, - run, - assertTaskFailed, - assertTaskDisabled, -} from './_utils.js'; - -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/prerequisite-tasks.js'); -const {packageJson: pkg} = await readPackageUp(); +import actualPrerequisiteTasks from '../../source/prerequisite-tasks.js'; +import {npPkg} from '../../source/util.js'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDisabled} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/prerequisite-tasks.js', import.meta.url); test.afterEach(() => { SilentRenderer.clearTasks(); }); -test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'npm ping', - exitCode: 1, - exitCodeName: 'EPERM', - stdout: '', - stderr: 'failed', - }]); - +test.serial('public-package published on npm registry: should fail when npm registry not pingable', createFixture, [{ + command: 'npm ping', + exitCode: 1, + exitCodeName: 'EPERM', + stdout: '', + stderr: 'failed', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.0.0', {name: 'test'}, {})), {message: 'Connection to npm registry failed'}, @@ -36,13 +28,10 @@ test.serial('public-package published on npm registry: should fail when npm regi assertTaskFailed(t, 'Ping npm registry'); }); -test.serial('private package: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); - +test.serial('private package: should disable task pinging npm registry', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), ); @@ -50,13 +39,10 @@ test.serial('private package: should disable task pinging npm registry', async t assertTaskDisabled(t, 'Ping npm registry'); }); -test.serial('external registry: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); - +test.serial('external registry: should disable task pinging npm registry', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), ); @@ -64,68 +50,56 @@ test.serial('external registry: should disable task pinging npm registry', async assertTaskDisabled(t, 'Ping npm registry'); }); -test.serial('should fail when npm version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm --version', - exitCode: 0, - stdout: '6.0.0', - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }, - ]); - - const depRange = pkg.engines.npm; +test.serial('should fail when npm version does not match range in `package.json`', createFixture, [ + { + command: 'npm --version', + stdout: '6.0.0', + }, + { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.npm; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to npm${depRange}`}, + {message: `\`np\` requires npm ${depRange}`}, ); assertTaskFailed(t, 'Check npm version'); }); -test.serial('should fail when yarn version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'yarn --version', - exitCode: 0, - stdout: '1.0.0', - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }, - ]); - - const depRange = pkg.engines.yarn; +test.serial('should fail when yarn version does not match range in `package.json`', createFixture, [ + { + command: 'yarn --version', + stdout: '1.0.0', + }, + { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.yarn; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), - {message: `Please upgrade to yarn${depRange}`}, + {message: `\`np\` requires yarn ${depRange}`}, ); assertTaskFailed(t, 'Check yarn version'); }); -test.serial('should fail when user is not authenticated at npm registry', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm whoami', - exitCode: 0, - stdout: 'sindresorhus', - }, - { - command: 'npm access ls-collaborators test', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - ]); - +test.serial('should fail when user is not authenticated at npm registry', createFixture, [ + { + command: 'npm whoami', + stdout: 'sindresorhus', + }, + { + command: 'npm access ls-collaborators test', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.throwsAsync( @@ -138,25 +112,20 @@ test.serial('should fail when user is not authenticated at npm registry', async assertTaskFailed(t, 'Verify user is authenticated'); }); -test.serial('should fail when user is not authenticated at external registry', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm whoami --registry http://my.io', - exitCode: 0, - stdout: 'sindresorhus', - }, - { - command: 'npm access ls-collaborators test --registry http://my.io', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - { - command: 'npm access list collaborators test --json --registry http://my.io', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - ]); - +test.serial('should fail when user is not authenticated at external registry', createFixture, [ + { + command: 'npm whoami --registry http://my.io', + stdout: 'sindresorhus', + }, + { + command: 'npm access ls-collaborators test --registry http://my.io', + stdout: '{"sindresorhus": "read"}', + }, + { + command: 'npm access list collaborators test --json --registry http://my.io', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.throwsAsync( @@ -169,13 +138,12 @@ test.serial('should fail when user is not authenticated at external registry', a assertTaskFailed(t, 'Verify user is authenticated'); }); -test.serial('private package: should disable task `verify user is authenticated`', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); +test.serial.todo('should not fail if no collaborators'); // Verify user is authenticated +test.serial('private package: should disable task `verify user is authenticated`', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.notThrowsAsync( @@ -187,31 +155,26 @@ test.serial('private package: should disable task `verify user is authenticated` assertTaskDisabled(t, 'Verify user is authenticated'); }); -test.serial('should fail when git version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git version', - exitCode: 0, - stdout: 'git version 1.0.0', - }]); - - const depRange = pkg.engines.git; +test.serial('should fail when git version does not match range in `package.json`', createFixture, [{ + command: 'git version', + stdout: 'git version 1.0.0', +}], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.git; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to git${depRange}`}, + {message: `\`np\` requires git ${depRange}`}, ); assertTaskFailed(t, 'Check git version'); }); -test.serial('should fail when git remote does not exist', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git ls-remote origin HEAD', - exitCode: 1, - exitCodeName: 'EPERM', - stderr: 'not found', - }]); - +test.serial('should fail when git remote does not exist', createFixture, [{ + command: 'git ls-remote origin HEAD', + exitCode: 1, + exitCodeName: 'EPERM', + stderr: 'not found', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), {message: 'not found'}, @@ -223,16 +186,16 @@ test.serial('should fail when git remote does not exist', async t => { test.serial('should fail when version is invalid', async t => { await t.throwsAsync( run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, + {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, ); assertTaskFailed(t, 'Validate version'); }); -test.serial('should fail when version is lower as latest version', async t => { +test.serial('should fail when version is lower than latest version', async t => { await t.throwsAsync( run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'New version `0.1.0` should be higher than current version `1.0.0`'}, + {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, ); assertTaskFailed(t, 'Validate version'); @@ -247,34 +210,28 @@ test.serial('should fail when prerelease version of public package without dist assertTaskFailed(t, 'Check for pre-release version'); }); -test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('should not fail when prerelease version of public package with dist tag given', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false, tag: 'pre'})), ); }); -test.serial('should not fail when prerelease version of private package without dist tag given', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), ); }); -test.serial('should fail when git tag already exists', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: 'vvb', - }]); - +test.serial('should fail when git tag already exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: 'vvb', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), {message: 'Git tag `v2.0.0` already exists.'}, @@ -283,12 +240,10 @@ test.serial('should fail when git tag already exists', async t => { assertTaskFailed(t, 'Check git tag existence'); }); -test.serial('checks should pass', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('checks should pass', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), ); diff --git a/test/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts new file mode 100644 index 00000000..10f1387e --- /dev/null +++ b/test/ui/new-files-dependencies.d.ts @@ -0,0 +1,34 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {PackageJson} from 'read-pkg'; + +type Context = { + createFile: (file: string, content?: string) => Promise; +}; + +type CommandsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; +}]; + +type ListItem = `- ${string}`; + +type Expected = { + unpublished: ListItem[]; + firstTime: ListItem[]; + dependencies: ListItem[]; +}; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; + logs: string[]; +}]; + +export type CreateFixtureMacro = Macro<[ + pkg: PackageJson, + commands: (...arguments_: CommandsFnParameters) => Promise, + expected: Expected, + assertions: (...arguments_: AssertionsFnParameters) => Promise, +], Context>; diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js new file mode 100644 index 00000000..97a46172 --- /dev/null +++ b/test/ui/new-files-dependencies.js @@ -0,0 +1,127 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {execa} from 'execa'; +import {removePackageDependencies, updatePackage} from 'write-pkg'; +import stripAnsi from 'strip-ansi'; +import {readPackage} from 'read-pkg'; +import {createIntegrationTest} from '../_helpers/integration-test.js'; +import {mockInquirer} from '../_helpers/mock-inquirer.js'; + +/** @param {string} message */ +const checkLines = message => ( + /** @param {import('ava').ExecutionContext} t @param {string[]} logs @param {string[]} expectedLines */ + (t, logs, expectedLines) => { + const lineAfterMessage = logs.indexOf(message) + 1; + const endOfList = logs.findIndex((log, ind) => ind > lineAfterMessage && !log.startsWith('-')); + + t.deepEqual(logs.slice(lineAfterMessage, endOfList), expectedLines); + } +); + +const checkNewUnpublished = checkLines('The following new files will not be part of your published package:'); +const checkFirstTimeFiles = checkLines('The following new files will be published for the first time:'); +const checkNewDependencies = checkLines('The following new dependencies will be part of your published package:'); + +/** @type {import('./new-files-dependencies.d.ts').CreateFixtureMacro} */ +const createFixture = test.macro(async (t, pkg, commands, expected) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + pkg = { + name: '@np/foo', + version: '0.0.0', + dependencies: {}, + ...pkg, + }; + + await updatePackage(temporaryDir, pkg); + + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + + await commands({t, $$, temporaryDir}); + pkg = await readPackage({cwd: temporaryDir}); + + const {ui, logs: logsArray} = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + }, + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'is-interactive': () => false, + }}); + + await ui({runPublish: true, version: 'major', yarn: false}, {pkg, rootDir: temporaryDir}); + const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); + + const {unpublished, firstTime, dependencies} = expected; + + const assertions = await t.try(tt => { + if (unpublished) { + checkNewUnpublished(tt, logs, unpublished); + } + + if (firstTime) { + checkFirstTimeFiles(tt, logs, firstTime); + } + + if (dependencies) { + checkNewDependencies(tt, logs, dependencies); + } + }); + + if (!assertions.passed) { + t.log('logs:', logs); + t.log('pkg:', pkg); + t.log('expected:', expected); + } + + assertions.commit(); + }); +}); + +test('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new']}); + +test('unpublished and first time', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new'], firstTime: ['- index.js']}); + +test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], dependencies: ['- cat-names']}); + +test('first time', createFixture, {}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {firstTime: ['- new']}); + +test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {firstTime: ['- new'], dependencies: ['- cat-names']}); + +test('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDir}) => { + await removePackageDependencies(temporaryDir, ['dog-names']); + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {dependencies: ['- cat-names']}); + +test('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], firstTime: ['- index.js'], dependencies: ['- cat-names']}); diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js new file mode 100644 index 00000000..52423b40 --- /dev/null +++ b/test/ui/prompts/tags.js @@ -0,0 +1,123 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npPkg} from '../../../source/util.js'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { + const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(tags), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPkg.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + }}); + + const results = await ui({ + runPublish: true, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose next', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'next', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'next'); +}); + +test('choose beta', testUi, { + version: '0.0.0', + tags: ['beta', 'stable'], + answers: { + version: 'prerelease', + tag: 'beta', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'beta'); +}); + +test('choose custom', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'Other (specify)', + customTag: 'alpha', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +test('choose custom - validation', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'Other (specify)', + customTag: [ + { + input: '', + error: 'Please specify a tag, for example, `next`.', + }, + { + input: 'latest', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'LAteSt', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'alpha', + }, + ], + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +// Assuming from version 0.0.0 +const fixtures = [ + {version: 'premajor', expected: '1.0.0-0'}, + {version: 'preminor', expected: '0.1.0-0'}, + {version: 'prepatch', expected: '0.0.1-0'}, + {version: 'prerelease', expected: '0.0.1-0'}, +]; + +for (const {version, expected} of fixtures) { + test(`works for ${version}`, testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version, + tag: 'next', + }, + }, ({t, results: {version, tag}}) => { + t.is(version.toString(), expected); + t.is(tag, 'next'); + }); +} diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js new file mode 100644 index 00000000..eb1021fb --- /dev/null +++ b/test/ui/prompts/version.js @@ -0,0 +1,128 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, answers}, assertions) => { + const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + }}); + + const results = await ui({ + runPublish: false, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose major', testUi, { + version: '0.0.0', + answers: { + version: 'major', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose minor', testUi, { + version: '0.0.0', answers: { + version: 'minor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0'); +}); + +test('choose patch', testUi, { + version: '0.0.0', answers: { + version: 'patch', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1'); +}); + +test('choose premajor', testUi, { + version: '0.0.0', answers: { + version: 'premajor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-0'); +}); + +test('choose preminor', testUi, { + version: '0.0.0', answers: { + version: 'preminor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0-0'); +}); + +test('choose prepatch', testUi, { + version: '0.0.0', answers: { + version: 'prepatch', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-0'); +}); + +test('choose prerelease', testUi, { + version: '0.0.1-0', answers: { + version: 'prerelease', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-1'); +}); + +test('choose custom', testUi, { + version: '0.0.0', answers: { + version: 'Other (specify)', + customVersion: '1.0.0', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose custom - validation', testUi, { + version: '1.0.0', answers: { + version: 'Other (specify)', + customVersion: [ + { + input: 'major', + error: 'Custom version should not be a SemVer increment.', + }, + { + input: '200', + error: 'Custom version 200 should be a valid SemVer version.', + }, + { + input: '0.0.0', + error: 'Custom version 0.0.0 should be higher than current version 1.0.0.', + }, + { + input: '1.0.0', + error: 'Custom version 1.0.0 should be higher than current version 1.0.0.', + }, + { + input: '2.0.0', + }, + ], + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '2.0.0'); +}); diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js new file mode 100644 index 00000000..862a26ef --- /dev/null +++ b/test/util/get-new-dependencies.js @@ -0,0 +1,44 @@ +import test from 'ava'; +import {updatePackage} from 'write-pkg'; +import {readPackage} from 'read-pkg'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js'); + +test('reports new dependencies since last release', createFixture, async ({$$, temporaryDir}) => { + await updatePackage(temporaryDir, {dependencies: {'dog-names': '^2.1.0'}}); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release)', createFixture, async ({temporaryDir}) => { + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release) - no deps', createFixture, async ({temporaryDir}) => { + await updatePackage(temporaryDir, {name: '@np/foo'}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + [], + ); +}); diff --git a/test/util/get-new-files.js b/test/util/get-new-files.js new file mode 100644 index 00000000..290f774f --- /dev/null +++ b/test/util/get-new-files.js @@ -0,0 +1,109 @@ +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import {execa} from 'execa'; +import {writePackage} from 'write-pkg'; +import {createIntegrationTest} from '../_helpers/integration-test.js'; + +const createNewFilesFixture = test.macro(async (t, input, commands) => { + const {pkgFiles, expected: {unpublished, firstTime}} = input; + + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + /** @type {import('../../source/util.js')} */ + const {getNewFiles} = await esmock('../../source/util.js', {}, { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + }); + + await commands({t, $$, temporaryDir}); + + await writePackage(temporaryDir, { + name: 'foo', + version: '0.0.0', + ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, + }); + + const assertions = await t.try(async tt => { + tt.deepEqual( + await getNewFiles(temporaryDir), + {unpublished, firstTime}, + ); + }); + + if (!assertions.passed) { + t.log(input); + } + + assertions.commit(); + }); +}); + +test('files to package with tags added', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: ['new'], + firstTime: ['index.js'], + }, +}, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}); + +test('file `new` to package without tags added', createNewFilesFixture, { + pkgFiles: ['index.js'], + expected: { + unpublished: ['new'], + firstTime: ['index.js', 'package.json'], + }, +}, async ({t}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); +}); + +(() => { // Wrapper to have constants with macro + const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); + const filePath1 = path.join(longPath, 'file1'); + const filePath2 = path.join(longPath, 'file2'); + + test('files with long pathnames added', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: [filePath1, filePath2], + firstTime: [], + }, + }, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile(filePath1); + await t.context.createFile(filePath2); + await $$`git add -A`; + await $$`git commit -m "added"`; + }); +})(); + +test('no new files added', createNewFilesFixture, { + pkgFiles: [], + expected: { + unpublished: [], + firstTime: [], + }, +}, async ({$$}) => { + await $$`git tag v0.0.0`; +}); + +test('ignores .git and .github files', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: [], + firstTime: ['index.js'], + }, +}, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('.github/workflows/main.yml'); + await t.context.createFile('.github/pull_request_template.md'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}); diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js new file mode 100644 index 00000000..113294c7 --- /dev/null +++ b/test/util/get-pre-release-prefix.js @@ -0,0 +1,72 @@ +import process from 'node:process'; +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {getPreReleasePrefix as originalGetPreReleasePrefix} from '../../source/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('returns preid postfix if set - npm', createFixture, [{ + command: 'npm config get preid', + stdout: 'pre', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: false}), + 'pre', + ); +}); + +test('returns preid postfix if set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'pre', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: true}), + 'pre', + ); +}); + +test('returns empty string if not set - npm', createFixture, [{ + command: 'npm config get preid', + stdout: 'undefined', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: false}), + '', + ); +}); + +test('returns empty string if not set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'undefined', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: true}), + '', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetPreReleasePrefix(), + {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}, + ); + + await t.throwsAsync( + originalGetPreReleasePrefix({}), + {message: 'Expected object to have keys `["yarn"]`'}, + ); +}); + +test.serial('returns actual value', async t => { + const originalPreid = process.env.NPM_CONFIG_PREID; + process.env.NPM_CONFIG_PREID = 'beta'; + + t.is(await originalGetPreReleasePrefix({yarn: false}), 'beta'); + + process.env.NPM_CONFIG_PREID = originalPreid; +}); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js new file mode 100644 index 00000000..aa387110 --- /dev/null +++ b/test/util/get-tag-version-prefix.js @@ -0,0 +1,52 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('returns tag prefix - npm', createFixture, [{ + command: 'npm config get tag-version-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: false}), + 'ver', + ); +}); + +test('returns preId postfix - yarn', createFixture, [{ + command: 'yarn config get version-tag-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: true}), + 'ver', + ); +}); + +test('defaults to "v" when command fails', createFixture, [{ + command: 'npm config get tag-version-prefix', + exitCode: 1, +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: false}), + 'v', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetTagVersionPrefix(), + {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}, + ); + + await t.throwsAsync( + originalGetTagVersionPrefix({}), + {message: 'Expected object to have keys `["yarn"]`'}, + ); +}); diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js new file mode 100644 index 00000000..8ddfb6fe --- /dev/null +++ b/test/util/hyperlinks.js @@ -0,0 +1,76 @@ +import test from 'ava'; +import esmock from 'esmock'; + +const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; +const MOCK_COMMIT_HASH = '5063f8a'; +const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; + +const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { + /** @type {typeof import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'supports-hyperlinks': { + stdout: linksSupported, + stderr: linksSupported, + }, + }); + + await assertions({t, util}); +}); + +test('linkifyIssues correctly links issues', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); +}); + +test('linkifyIssues returns raw message if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #5'; + t.is(linkifyIssues(undefined, message), message); +}); + +test('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #6'; + t.is(linkifyIssues(MOCK_REPO_URL, message), message); +}); + +test('linkifyCommit correctly links commits', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); +}); + +test('linkifyCommit returns raw commit hash if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); +}); + +test('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); +}); + +test('linkifyCommitRange returns raw commitRange if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); +}); + +test('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); +}); + +test('linkifyCommitRange correctly links commit range', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), ']8;;https://github.com/unicorn/rainbow/compare/5063f8a...master5063f8a...master]8;;'); +}); diff --git a/test/util/join-list.js b/test/util/join-list.js new file mode 100644 index 00000000..3257adf9 --- /dev/null +++ b/test/util/join-list.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {joinList} from '../../source/util.js'; + +const testJoinList = test.macro((t, {list, expected}) => { + const output = joinList(list); + t.is(stripAnsi(output), expected); +}); + +test('one item', testJoinList, { + list: ['foo'], + expected: '- foo', +}); + +test('two items', testJoinList, { + list: ['foo', 'bar'], + expected: '- foo\n- bar', +}); + +test('multiple items', testJoinList, { + list: ['foo', 'bar', 'baz'], + expected: '- foo\n- bar\n- baz', +}); diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js new file mode 100644 index 00000000..e9dd49c8 --- /dev/null +++ b/test/util/read-pkg.js @@ -0,0 +1,49 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import {temporaryDirectory} from 'tempy'; +import {readPkg, npPkg, npRootDir} from '../../source/util.js'; + +const rootDir = fileURLToPath(new URL('../..', import.meta.url)).slice(0, -1); + +test('without packagePath returns np package.json', async t => { + const {pkg, rootDir: pkgDir} = await readPkg(); + + t.is(pkg.name, 'np'); + t.is(pkgDir, rootDir); +}); + +test('with packagePath', async t => { + const fixtureDir = path.resolve(rootDir, 'test/fixtures/files/one-file'); + const {pkg, rootDir: pkgDir} = await readPkg(fixtureDir); + + t.is(pkg.name, 'foo'); + t.is(pkgDir, fixtureDir); +}); + +test('no package.json', async t => { + await t.throwsAsync( + readPkg(temporaryDirectory()), + {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, + ); +}); + +test('npPkg', t => { + t.is(npPkg.name, 'np'); +}); + +test('npRootDir', t => { + t.is(npRootDir, rootDir); +}); + +test('npRootDir is correct when process.cwd is different', async t => { + const temporaryDir = temporaryDirectory(); + + /** @type {import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'node:process': {cwd: temporaryDir}, + }); + + t.is(util.npRootDir, rootDir); +}); diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js new file mode 100644 index 00000000..6bd75602 --- /dev/null +++ b/test/util/validate-engine-version-satisfies.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {validateEngineVersionSatisfies, npPkg} from '../../source/util.js'; + +const testEngineRanges = test.macro((t, engine, {above, below}) => { + const range = npPkg.engines[engine]; + + t.notThrows( + () => validateEngineVersionSatisfies(engine, above), // One above minimum + ); + + t.throws( + () => validateEngineVersionSatisfies(engine, below), // One below minimum + {message: `\`np\` requires ${engine} ${range}`}, + ); +}); + +test('node', testEngineRanges, 'node', {above: '16.7.0', below: '16.5.0'}); + +test('npm', testEngineRanges, 'npm', {above: '7.20.0', below: '7.18.0'}); + +test('git', testEngineRanges, 'git', {above: '2.12.0', below: '2.10.0'}); + +test('yarn', testEngineRanges, 'yarn', {above: '1.8.0', below: '1.6.0'}); + diff --git a/test/version.js b/test/version.js index 203fb4e2..30e203f5 100644 --- a/test/version.js +++ b/test/version.js @@ -1,139 +1,234 @@ import test from 'ava'; +import sinon from 'sinon'; +import {template as chalk} from 'chalk-template'; +import semver from 'semver'; import Version from '../source/version.js'; -test('version.SEMVER_INCREMENTS', t => { - t.deepEqual(Version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); +const INCREMENT_LIST = 'patch, minor, major, prepatch, preminor, premajor, prerelease'; +const INCREMENT_LIST_OR = 'patch, minor, major, prepatch, preminor, premajor, or prerelease'; + +/** @param {string} input - Place `{ }` around the version parts to be highlighted. */ +const makeNewFormattedVersion = input => { + input = input.replaceAll(/{([^}]*)}/g, '{cyan $1}'); // https://regex101.com/r/rZUIp4/1 + return chalk(`{dim ${input}}`); +}; + +test('new Version - valid', t => { + t.is(new Version('1.0.0').toString(), '1.0.0'); }); -test('version.PRERELEASE_VERSIONS', t => { - t.deepEqual(Version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); +test('new Version - invalid', t => { + t.throws( + () => new Version('major'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isValidInput', t => { - t.false(Version.isValidInput(null)); - t.false(Version.isValidInput('foo')); - t.false(Version.isValidInput('1.0.0.0')); +test('new Version - valid w/ valid increment', t => { + t.is(new Version('1.0.0', 'major').toString(), '2.0.0'); +}); - t.true(Version.isValidInput('patch')); - t.true(Version.isValidInput('minor')); - t.true(Version.isValidInput('major')); - t.true(Version.isValidInput('prepatch')); - t.true(Version.isValidInput('preminor')); - t.true(Version.isValidInput('premajor')); - t.true(Version.isValidInput('prerelease')); - t.true(Version.isValidInput('1.0.0')); - t.true(Version.isValidInput('1.1.0')); - t.true(Version.isValidInput('1.0.1')); - t.true(Version.isValidInput('1.0.0-beta')); - t.true(Version.isValidInput('2.0.0-rc.2')); +test('new Version - invalid w/ valid increment', t => { + t.throws( + () => new Version('major', 'major'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isPrerelease', t => { - t.false(new Version('1.0.0').isPrerelease()); - t.false(new Version('1.1.0').isPrerelease()); - t.false(new Version('1.0.1').isPrerelease()); +test('new Version - valid w/ invalid increment', t => { + t.throws( + () => new Version('1.0.0', '2.0.0'), + {message: `Increment 2.0.0 should be one of ${INCREMENT_LIST_OR}.`}, + ); +}); - t.true(new Version('1.0.0-beta').isPrerelease()); - t.true(new Version('2.0.0-rc.2').isPrerelease()); +test('new Version - invalid w/ invalid increment', t => { + t.throws( + () => new Version('major', '2.0.0'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isPrereleaseOrIncrement', t => { - t.false(Version.isPrereleaseOrIncrement('patch')); - t.false(Version.isPrereleaseOrIncrement('minor')); - t.false(Version.isPrereleaseOrIncrement('major')); +// Input as SemVer increment is covered in constructor tests +test('setFrom - valid input as version', t => { + t.is(new Version('1.0.0').setFrom('2.0.0').toString(), '2.0.0'); +}); - t.true(Version.isPrereleaseOrIncrement('prepatch')); - t.true(Version.isPrereleaseOrIncrement('preminor')); - t.true(Version.isPrereleaseOrIncrement('premajor')); - t.true(Version.isPrereleaseOrIncrement('prerelease')); +test('setFrom - invalid input as version', t => { + t.throws( + () => new Version('1.0.0').setFrom('200'), + {message: `New version 200 should either be one of ${INCREMENT_LIST}, or a valid SemVer version.`}, + ); }); -test('version.getNewVersionFrom', t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; +test('setFrom - valid input is not higher than version', t => { + t.throws( + () => new Version('1.0.0').setFrom('0.2.0'), + {message: 'New version 0.2.0 should be higher than current version 1.0.0.'}, + ); +}); - t.throws(() => new Version('1.0.0').getNewVersionFrom('patchxxx'), {message}); - t.throws(() => new Version('1.0.0').getNewVersionFrom('1.0.0.0'), {message}); +test('format', t => { + t.is(new Version('0.0.0').format(), makeNewFormattedVersion('0.0.0')); +}); - t.is(new Version('1.0.0').getNewVersionFrom('patch'), '1.0.1'); - t.is(new Version('1.0.0').getNewVersionFrom('minor'), '1.1.0'); - t.is(new Version('1.0.0').getNewVersionFrom('major'), '2.0.0'); +test('format - set diff', t => { + t.is( + new Version('1.0.0').format({previousVersion: '0.0.0'}), + makeNewFormattedVersion('{1}.0.0'), + ); +}); - t.is(new Version('1.0.0-beta').getNewVersionFrom('major'), '1.0.0'); - t.is(new Version('1.0.0').getNewVersionFrom('prepatch'), '1.0.1-0'); - t.is(new Version('1.0.1-0').getNewVersionFrom('prepatch'), '1.0.2-0'); +test('format - major', t => { + const newVersion = makeNewFormattedVersion('{1}.0.0'); - t.is(new Version('1.0.0-0').getNewVersionFrom('prerelease'), '1.0.0-1'); - t.is(new Version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); + t.is(new Version('0.0.0').setFrom('major').format(), newVersion); + t.is(new Version('0.0.0').setFrom('1.0.0').format(), newVersion); }); -test('version.validate', t => { - const message = 'Version should be a valid semver version.'; +test('format - minor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.0'); + + t.is(new Version('0.0.0').setFrom('minor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0').format(), newVersion); +}); - t.throws(() => Version.validate('patch'), {message}); - t.throws(() => Version.validate('patchxxx'), {message}); - t.throws(() => Version.validate('1.0.0.0'), {message}); +test('format - patch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}'); - t.notThrows(() => Version.validate('1.0.0')); - t.notThrows(() => Version.validate('1.0.0-beta')); - t.notThrows(() => Version.validate('1.0.0-0')); + t.is(new Version('0.0.0').setFrom('patch').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1').format(), newVersion); }); -test('version.isGreaterThanOrEqualTo', t => { - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); +test('format - premajor', t => { + const newVersion = makeNewFormattedVersion('{1}.0.0-{0}'); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-0')); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-beta')); + t.is(new Version('0.0.0').setFrom('premajor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('1.0.0-0').format(), newVersion); +}); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.1')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.1.0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0')); +test('format - preminor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.0-{0}'); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); + t.is(new Version('0.0.0').setFrom('preminor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0-0').format(), newVersion); }); -test('version.isLowerThanOrEqualTo', t => { - t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.0.1')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.1.0')); +test('format - prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-0')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-beta')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0')); + t.is(new Version('0.0.0').setFrom('prepatch').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-0').format(), newVersion); +}); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.0.1')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.1.0')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0')); +test('format - prerelease', t => { + const newVersion = makeNewFormattedVersion('0.0.0-{1}'); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-0')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); + t.is(new Version('0.0.0-0').setFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0-0').setFrom('0.0.0-1').format(), newVersion); }); -test('version.satisfies', t => { +test('format - prerelease as prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); + + t.is(new Version('0.0.0').setFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-0').format(), newVersion); +}); + +test('format - prerelease with multiple numbers', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-0.0').format(), newVersion); +}); + +test('format - prerelease with text', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{alpha.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); +}); + +test('format - prerelease diffs', t => { + const newVersion = makeNewFormattedVersion('0.0.0-1.{2}'); + + t.is( + new Version('0.0.0-1.1').setFrom('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); + + t.is( + new Version('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); +}); + +test('format - custom colors', t => { + t.is( + new Version('1.2.3').format({color: 'green'}), + chalk('{green 1.2.3}'), + ); + + t.is( + new Version('1.2.3', 'minor').format({diffColor: 'red'}), + chalk('{dim 1.{red 3}.0}'), + ); + + t.is( + new Version('1.2.3', 'patch').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}}'), + ); + + t.is( + new Version('1.2.3', 'prerelease').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}-{yellow 0}}'), + ); +}); + +test('format - previousVersion as SemVer instance', t => { + const previousVersion = semver.parse('0.0.0'); + const newVersion = makeNewFormattedVersion('{1}.0.0'); + + const spy = sinon.spy(semver, 'parse'); + + t.is(new Version('1.0.0').format({previousVersion}), newVersion); + t.true(spy.calledOnce, 'semver.parse was called for previousVersion!'); + + spy.resetHistory(); + + t.is(new Version('1.0.0').format({previousVersion: '0.0.0'}), newVersion); + t.true(spy.calledTwice, 'semver.parse was not called for previousVersion!'); +}); + +test('format - invalid previousVersion', t => { + t.throws( + () => new Version('1.0.0').format({previousVersion: '000'}), + {message: 'Previous version 000 should be a valid SemVer version.'}, + ); +}); + +test('satisfies', t => { t.true(new Version('2.15.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('2.99.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('3.10.1').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('6.7.0-next.0').satisfies('<6.8.0')); + t.false(new Version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.false(new Version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); -}); - -test('version.getAndValidateNewVersionFrom', t => { - t.is(Version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); t.throws( - () => Version.getAndValidateNewVersionFrom('patch', '1'), - {message: 'Version should be a valid semver version.'}, + () => new Version('1.2.3').satisfies('=>1.0.0'), + {message: 'Range =>1.0.0 is not a valid SemVer range.'}, ); +}); - t.throws( - () => Version.getAndValidateNewVersionFrom('lol', '1.0.0'), - {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, - ); +test('isPrerelease', t => { + t.false(new Version('1.0.0').isPrerelease()); + t.false(new Version('1.1.0').isPrerelease()); + t.false(new Version('1.0.1').isPrerelease()); - t.throws( - () => Version.getAndValidateNewVersionFrom('1.0.0', '2.0.0'), - {message: 'New version `1.0.0` should be higher than current version `2.0.0`'}, - ); + t.true(new Version('1.0.0-alpha.1').isPrerelease()); + t.true(new Version('1.0.0-beta').isPrerelease()); + t.true(new Version('2.0.0-rc.2').isPrerelease()); +}); + +test('optionally set prereleasePrefix', t => { + t.is(new Version('1.0.0', 'prerelease', {prereleasePrefix: 'alpha'}).toString(), '1.0.1-alpha.0'); + t.is(new Version('1.0.0').setFrom('prerelease', {prereleasePrefix: 'beta'}).toString(), '1.0.1-beta.0'); });