From d3df4f8b723f3204eb4f6e84badfe6eb61d72c05 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 6 Apr 2023 16:55:14 -0500 Subject: [PATCH 01/63] chore: start separating tests --- test/_utils.js | 4 ++-- test/{ => npm}/packed-files.js | 4 ++-- test/{ => tasks}/git-tasks.js | 8 ++++---- test/{ => tasks}/prerequisite-tasks.js | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) rename test/{ => npm}/packed-files.js (95%) rename test/{ => tasks}/git-tasks.js (95%) rename test/{ => tasks}/prerequisite-tasks.js (96%) diff --git a/test/_utils.js b/test/_utils.js index c87b122f..1c295c17 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -22,10 +22,10 @@ const makeExecaStub = commands => { return stub; }; -export const _stubExeca = source => async commands => { +export const _stubExeca = (source, importMeta) => async commands => { const execaStub = makeExecaStub(commands); - return esmock(source, {}, { + return esmock(source, importMeta, {}, { execa: { execa: async (...args) => execaStub.resolves(execa(...args))(...args), }, diff --git a/test/packed-files.js b/test/npm/packed-files.js similarity index 95% rename from test/packed-files.js rename to test/npm/packed-files.js index b2a44aad..0faf095f 100644 --- a/test/packed-files.js +++ b/test/npm/packed-files.js @@ -1,8 +1,8 @@ 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 '../_utils.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); diff --git a/test/git-tasks.js b/test/tasks/git-tasks.js similarity index 95% rename from test/git-tasks.js rename to test/tasks/git-tasks.js index c39963d3..f5d8aed3 100644 --- a/test/git-tasks.js +++ b/test/tasks/git-tasks.js @@ -1,14 +1,14 @@ import test from 'ava'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; +import {SilentRenderer} from '../fixtures/listr-renderer.js'; import { _stubExeca, run, assertTaskFailed, assertTaskDoesntExist, -} from './_utils.js'; +} from '../_utils.js'; -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/git-tasks.js'); +/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ +const stubExeca = _stubExeca('../../source/git-tasks.js', import.meta.url); test.afterEach(() => { SilentRenderer.clearTasks(); diff --git a/test/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js similarity index 96% rename from test/prerequisite-tasks.js rename to test/tasks/prerequisite-tasks.js index 9be09523..21284913 100644 --- a/test/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,18 +1,18 @@ 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 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'; +} from '../_utils.js'; -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/prerequisite-tasks.js'); +/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ +const stubExeca = _stubExeca('../../source/prerequisite-tasks.js', import.meta.url); const {packageJson: pkg} = await readPackageUp(); test.afterEach(() => { From 636eb0b62682c60d8b85bbb8fd3410914acd3727 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 7 Apr 2023 17:09:13 -0500 Subject: [PATCH 02/63] tests(`git`): add tests for `git-util.js` --- source/git-util.js | 29 ++-- test/_helpers/integration-test.d.ts | 24 ++++ test/_helpers/integration-test.js | 41 ++++++ test/_helpers/stub-execa.d.ts | 14 ++ test/_helpers/stub-execa.js | 40 ++++++ test/git/integration.js | 202 ++++++++++++++++++++++++++++ test/git/stub.js | 110 +++++++++++++++ test/git/unit.js | 27 ++++ 8 files changed, 473 insertions(+), 14 deletions(-) create mode 100644 test/_helpers/integration-test.d.ts create mode 100644 test/_helpers/integration-test.js create mode 100644 test/_helpers/stub-execa.d.ts create mode 100644 test/_helpers/stub-execa.js create mode 100644 test/git/integration.js create mode 100644 test/git/stub.js create mode 100644 test/git/unit.js diff --git a/source/git-util.js b/source/git-util.js index 7b114cb8..638eb3c7 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -6,6 +6,7 @@ import Version from './version.js'; export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); + console.log('hi ' + stdout); return stdout; }; @@ -38,6 +39,12 @@ export const readFileFromLastRelease = async file => { return oldFile; }; +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'); +}; + 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,7 @@ export const fetch = async () => { await execa('git', ['fetch']); }; -export const tagExistsOnRemote = async tagName => { +const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); @@ -202,14 +203,14 @@ export const tagExistsOnRemote = async tagName => { } }; -async function hasLocalBranch(branch) { +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']) { @@ -233,6 +234,10 @@ export const commitLogFromRevision = async revision => { return stdout; }; +const push = async () => { + await execa('git', ['push', '--follow-tags']); +}; + export const pushGraceful = async remoteIsOnGitHub => { try { await push(); @@ -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]); }; diff --git a/test/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts new file mode 100644 index 00000000..54afcf0a --- /dev/null +++ b/test/_helpers/integration-test.d.ts @@ -0,0 +1,24 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {Execa$} from 'execa'; + +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, +], { + createFile: (file: string, content?: string) => Promise; +}>; + +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..bc2fedb6 --- /dev/null +++ b/test/_helpers/integration-test.js @@ -0,0 +1,41 @@ +/* eslint-disable ava/no-ignored-test-files */ +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) => { + 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"`; +}; + +export const createIntegrationTest = async (t, assertions) => { + await temporaryDirectoryTask(async temporaryDir => { + const $$ = $({cwd: temporaryDir}); + + await createEmptyGitRepo($$, temporaryDir); + + t.context.createFile = async (file, content = '') => fs.writeFile(path.resolve(temporaryDir, file), content); + 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/_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..6b39b563 --- /dev/null +++ b/test/_helpers/stub-execa.js @@ -0,0 +1,40 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import {execa} from 'execa'; + +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; +}; + +const _stubExeca = (source, importMeta) => async commands => { + const execaStub = makeExecaStub(commands); + + return esmock(source, importMeta, {}, { + execa: { + execa: async (...args) => execaStub.resolves(execa(...args))(...args), + }, + }); +}; + +export const _createFixture = (source, importMeta) => test.macro(async (t, commands, assertions) => { + const stubExeca = _stubExeca(source, importMeta); + const testedModule = await stubExeca(commands); + await assertions({t, testedModule}); +}); diff --git a/test/git/integration.js b/test/git/integration.js new file mode 100644 index 00000000..4a8e8053 --- /dev/null +++ b/test/git/integration.js @@ -0,0 +1,202 @@ +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('git-util.newFilesSinceLastRelease', createFixture, 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"`; +}, async ({t, testedModule: git, temporaryDir}) => { + const newFiles = await git.newFilesSinceLastRelease(temporaryDir); + t.deepEqual(newFiles.sort(), ['new', 'index.js'].sort()); +}); + +test('git-util.newFilesSinceLastRelease - no files', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: git, temporaryDir}) => { + const newFiles = await git.newFilesSinceLastRelease(temporaryDir); + t.deepEqual(newFiles, []); +}); + +test('git-util.newFilesSinceLastRelease - use 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: git, temporaryDir}) => { + const newFiles = await git.newFilesSinceLastRelease(temporaryDir); + t.deepEqual(newFiles.sort(), ['index.js', 'package.json', '.gitignore'].sort()); +}); + +// TODO: `tagList` always has a minimum length of 1 -> `''.split('\n')` => `['']` +test.failing('git-util.previousTagOrFirstCommit - no tags', createFixture, () => {}, async ({t, testedModule: git}) => { + const result = await git.previousTagOrFirstCommit(); + t.is(result, undefined); +}); + +test('git-util.previousTagOrFirstCommit - one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: git, $$}) => { + const result = await git.previousTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + t.is(firstCommitMessage.trim(), '"init1"'); +}); + +test('git-util.previousTagOrFirstCommit - two tags', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; + await $$`git tag v2.0.0`; +}, async ({t, testedModule: git}) => { + const result = await git.previousTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +// TODO: git-util.previousTagOrFirstCommit - test fallback case + +test('git-util.latestTagOrFirstCommit - one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: git}) => { + const result = await git.latestTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +// TODO: is this intended behavior? I'm not sure +test.failing('git-util.latestTagOrFirstCommit - two tags', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; +}, async ({t, testedModule: git}) => { + const result = await git.latestTagOrFirstCommit(); + t.is(result, 'v1.0.0'); +}); + +test('git-util.latestTagOrFirstCommit - no tags (fallback)', createFixture, async () => {}, async ({t, testedModule: git, $$}) => { + const result = await git.latestTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + t.is(firstCommitMessage.trim(), '"init1"'); +}); + +test('git-util.hasUpstream', createFixture, async () => {}, async ({t, testedModule: git}) => { + t.false(await git.hasUpstream()); +}); + +test('git-util.getCurrentBranch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: git}) => { + const currentBranch = await git.getCurrentBranch(); + t.is(currentBranch, 'unicorn'); +}); + +test('git-util.isHeadDetached - not detached', createFixture, async () => {}, async ({t, testedModule: git}) => { + t.false(await git.isHeadDetached()); +}); + +test('git-util.isHeadDetached - detached', createFixture, async ({$$}) => { + const {stdout: firstCommitSha} = await $$`git rev-list --max-parents=0 HEAD`; + await $$`git checkout ${firstCommitSha}`; +}, async ({t, testedModule: git}) => { + t.true(await git.isHeadDetached()); +}); + +test('git-util.verifyWorkingTreeIsClean - clean', createFixture, async ({t, $$}) => { + t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git}) => { + await t.notThrowsAsync( + git.verifyWorkingTreeIsClean(), + ); +}); + +test('git-util.verifyWorkingTreeIsClean - not clean', createFixture, async ({t}) => { + t.context.createFile('index.js'); +}, async ({t, testedModule: git}) => { + await t.throwsAsync( + git.verifyWorkingTreeIsClean(), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); +}); + +test('git-util.verifyRemoteHistoryIsClean - no remote', createFixture, async () => {}, async ({t, testedModule: git}) => { + const result = await t.notThrowsAsync( + git.verifyRemoteHistoryIsClean(), + ); + + t.is(result, undefined); +}); + +test('git-util.verifyRemoteIsValid - no remote', createFixture, async () => {}, async ({t, testedModule: git}) => { + await t.throwsAsync( + git.verifyRemoteIsValid(), + {message: /^Git fatal error:/m}, + ); +}); + +test('git-util.defaultBranch - main', createFixture, async ({$$}) => { + await $$`git checkout -B main`; +}, async ({t, testedModule: git}) => { + t.is(await git.defaultBranch(), 'main'); +}); + +test('git-util.defaultBranch - master', createFixture, async ({$$}) => { + await $$`git switch -c master`; + await $$`git branch -D main`; +}, async ({t, testedModule: git}) => { + t.is(await git.defaultBranch(), 'master'); +}); + +test('git-util.defaultBranch - gh-pages', createFixture, async ({$$}) => { + await $$`git switch -c gh-pages`; + await $$`git branch -D main`; +}, async ({t, testedModule: git}) => { + t.is(await git.defaultBranch(), 'gh-pages'); +}); + +test('git-util.defaultBranch - fails', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; + await $$`git branch -D main`; +}, async ({t, testedModule: git}) => { + await t.throwsAsync( + git.defaultBranch(), + {message: 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'}, + ); +}); + +test('git-util.commitLogFromRevision', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git, $$}) => { + const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; + t.is(await git.commitLogFromRevision('v0.0.0'), `"added" ${lastCommitSha}`); +}); + +test('git-util.deleteTag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; +}, async ({t, testedModule: git, $$}) => { + await git.deleteTag('v1.0.0'); + const {stdout: tags} = await $$`git tag`; + t.is(tags, 'v0.0.0'); +}); + +test('git-util.removeLastCommit', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git, $$}) => { + const {stdout: commitsBefore} = await $$`git log --pretty="%s"`; + t.true(commitsBefore.includes('"added"')); + + await git.removeLastCommit(); + + const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; + t.false(commitsAfter.includes('"added"')); +}); diff --git a/test/git/stub.js b/test/git/stub.js new file mode 100644 index 00000000..4487ee64 --- /dev/null +++ b/test/git/stub.js @@ -0,0 +1,110 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('git-util.verifyRemoteHistoryIsClean - unfetched changes', createFixture, [ + { + 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: git}) => { + await t.throwsAsync( + git.verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); +}); + +test('git-util.verifyRemoteHistoryIsClean - unclean remote history', createFixture, [ + { + 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: git}) => { + await t.throwsAsync( + git.verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please pull changes.'}, + ); +}); + +test('git-util.verifyRemoteHistoryIsClean - clean fetched remote history', createFixture, [ + { + 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: git}) => { + await t.notThrowsAsync( + git.verifyRemoteHistoryIsClean(), + ); +}); + +test('git-util.verifyRemoteIsValid - has remote', createFixture, [{ + command: 'git ls-remote origin HEAD', + exitCode: 0, +}], async ({t, testedModule: git}) => { + await t.notThrowsAsync( + git.verifyRemoteIsValid(), + ); +}); + +test('git-util.verifyTagDoesNotExistOnRemote - exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + stdout: '123456789', // Some hash +}], async ({t, testedModule: git}) => { + await t.throwsAsync( + git.verifyTagDoesNotExistOnRemote('v0.0.0'), + {message: 'Git tag `v0.0.0` already exists.'}, + ); +}); + +test('git-util.verifyTagDoesNotExistOnRemote - does not exist', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + exitCode: 1, + stderr: '', + stdout: '', +}], async ({t, testedModule: git}) => { + await t.notThrowsAsync( + git.verifyTagDoesNotExistOnRemote('v0.0.0'), + ); +}); + +test('git-util.verifyRecentGitVersion - satisfies', createFixture, [{ + command: 'git version', + stdout: 'git version 2.12.0', +}], async ({t, testedModule: git}) => { + await t.notThrowsAsync( + git.verifyRecentGitVersion(), + ); +}); + +test('git-util.verifyRecentGitVersion - not satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.10.0', +}], async ({t, testedModule: git}) => { + await t.throwsAsync( + git.verifyRecentGitVersion(), + {message: 'Please upgrade to git>=2.11.0'}, // TODO: add space to error message? + ); +}); + diff --git a/test/git/unit.js b/test/git/unit.js new file mode 100644 index 00000000..ae20aa27 --- /dev/null +++ b/test/git/unit.js @@ -0,0 +1,27 @@ +import path from 'node:path'; +import test from 'ava'; +import {readPackageUp} from 'read-pkg-up'; +import * as git from '../../source/git-util.js'; + +const package_ = await readPackageUp(); +const {packageJson: pkg, path: pkgPath} = package_; +const rootDir = path.dirname(pkgPath); + +test('git-util.latestTag', async t => { + const version = `v${pkg.version}`; + t.is(await git.latestTag(), version); +}); + +test('git-util.root', async t => { + t.is(await git.root(), rootDir); +}); + +test('git-util.readFileFromLastRelease', async t => { + const oldPkg = await git.readFileFromLastRelease(pkgPath); + t.is(JSON.parse(oldPkg).name, 'np'); +}); + +test('git-util.checkIfFileGitIgnored', async t => { + t.false(await git.checkIfFileGitIgnored(pkgPath)); + t.true(await git.checkIfFileGitIgnored(path.resolve(rootDir, 'yarn.lock'))); +}); From e822ae919e163f610fb128b80a2b9ab6da323a2d Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 7 Apr 2023 21:30:15 -0500 Subject: [PATCH 03/63] further separate tests, add `npm` tests, style --- package.json | 4 + source/git-util.js | 1 - source/npm/handle-npm-error.js | 2 +- source/npm/util.js | 16 +- test/{fixtures => _helpers}/listr-renderer.js | 0 test/_helpers/listr.js | 20 ++ test/_helpers/util.js | 5 + test/_utils.js | 58 ---- test/config.js | 8 +- test/git/stub.js | 6 +- test/npm/packed-files.js | 2 +- test/npm/util/stub.js | 89 ++++++ test/npm/util/unit.js | 10 + test/tasks/git-tasks.js | 262 ++++++++---------- test/tasks/prerequisite-tasks.js | 235 +++++++--------- test/{ => util}/hyperlinks.js | 3 +- test/{ => util}/integration.js | 44 +-- test/{ => util}/prefix.js | 10 +- test/{ => util}/preid.js | 2 +- 19 files changed, 374 insertions(+), 403 deletions(-) rename test/{fixtures => _helpers}/listr-renderer.js (100%) create mode 100644 test/_helpers/listr.js create mode 100644 test/_helpers/util.js delete mode 100644 test/_utils.js create mode 100644 test/npm/util/stub.js create mode 100644 test/npm/util/unit.js rename test/{ => util}/hyperlinks.js (97%) rename test/{ => util}/integration.js (67%) rename test/{ => util}/prefix.js (68%) rename test/{ => util}/preid.js (90%) diff --git a/package.json b/package.json index 3a02660a..be918d23 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,10 @@ "xo": "^0.53.1" }, "ava": { + "files": [ + "!test/fixtures", + "!test/_helpers" + ], "environmentVariables": { "FORCE_HYPERLINK": "1" }, diff --git a/source/git-util.js b/source/git-util.js index 638eb3c7..1071dba4 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -6,7 +6,6 @@ import Version from './version.js'; export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); - console.log('hi ' + stdout); return stdout; }; 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..e0ca465e 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -33,18 +33,24 @@ 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 === 'object' && 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]; + const args = semver.satisfies(npmVersion, '>=9.0.0') + ? ['access', 'list', 'collaborators', packageName, '--json'] + : ['access', 'ls-collaborators', packageName]; + if (isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); } @@ -116,9 +122,7 @@ 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 version = async () => { const {stdout} = await execa('npm', ['--version']); return stdout; }; 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/util.js b/test/_helpers/util.js new file mode 100644 index 00000000..1f801bb6 --- /dev/null +++ b/test/_helpers/util.js @@ -0,0 +1,5 @@ +export const runIfExists = async (func, ...args) => { + if (typeof func === 'function') { + await func(...args); + } +}; diff --git a/test/_utils.js b/test/_utils.js deleted file mode 100644 index 1c295c17..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, importMeta) => async commands => { - const execaStub = makeExecaStub(commands); - - return esmock(source, importMeta, {}, { - 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/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/git/stub.js b/test/git/stub.js index 4487ee64..07f01045 100644 --- a/test/git/stub.js +++ b/test/git/stub.js @@ -89,9 +89,9 @@ test('git-util.verifyTagDoesNotExistOnRemote - does not exist', createFixture, [ ); }); -test('git-util.verifyRecentGitVersion - satisfies', createFixture, [{ +test('git-util.verifyRecentGitVersion - satisfied', createFixture, [{ command: 'git version', - stdout: 'git version 2.12.0', + stdout: 'git version 2.12.0', // One higher than minimum }], async ({t, testedModule: git}) => { await t.notThrowsAsync( git.verifyRecentGitVersion(), @@ -100,7 +100,7 @@ test('git-util.verifyRecentGitVersion - satisfies', createFixture, [{ test('git-util.verifyRecentGitVersion - not satisfied', createFixture, [{ command: 'git version', - stdout: 'git version 2.10.0', + stdout: 'git version 2.10.0', // One lower than minimum }], async ({t, testedModule: git}) => { await t.throwsAsync( git.verifyRecentGitVersion(), diff --git a/test/npm/packed-files.js b/test/npm/packed-files.js index 0faf095f..09bd8db3 100644 --- a/test/npm/packed-files.js +++ b/test/npm/packed-files.js @@ -2,7 +2,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 {runIfExists} from '../_helpers/util.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); diff --git a/test/npm/util/stub.js b/test/npm/util/stub.js new file mode 100644 index 00000000..41eccbdd --- /dev/null +++ b/test/npm/util/stub.js @@ -0,0 +1,89 @@ +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('npm.checkConnection - success', createFixture, [{ + command: 'npm ping', + exitCode: 0, +}], async ({t, testedModule: npm}) => { + t.true(await npm.checkConnection()); +}); + +test('npm.checkConnection - fail', createFixture, [{ + command: 'npm ping', + exitCode: 1, +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.checkConnection(), + {message: 'Connection to npm registry failed'}, + ); +}); + +// TODO: find way to timeout without timing out ava +test.failing('npm.checkConnection - timeout', async t => { + 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'}, + ); +}); + +test('npm.username', createFixture, [{ + command: 'npm whoami', + stdout: 'sindresorhus', +}], async ({t, testedModule: npm}) => { + t.is(await npm.username({}), 'sindresorhus'); +}); + +test('npm.username - --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('npm.username - 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('npm.username - 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.'}, + ); +}); + +test('npm.verifyRecentNpmVersion - satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.20.0', // One higher than minimum +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync( + npm.verifyRecentNpmVersion(), + ); +}); + +test('npm.verifyRecentNpmVersion - not satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.18.0', // One lower than minimum +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.verifyRecentNpmVersion(), + {message: 'Please upgrade to npm>=7.19.0'}, // TODO: add space to error message? + ); +}); diff --git a/test/npm/util/unit.js b/test/npm/util/unit.js new file mode 100644 index 00000000..41b105d4 --- /dev/null +++ b/test/npm/util/unit.js @@ -0,0 +1,10 @@ +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +test('npm.isExternalRegistry', t => { + t.true(npm.isExternalRegistry({publishConfig: {registry: 'http://my.io'}})); + + 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/tasks/git-tasks.js b/test/tasks/git-tasks.js index f5d8aed3..89d4aef7 100644 --- a/test/tasks/git-tasks.js +++ b/test/tasks/git-tasks.js @@ -1,25 +1,19 @@ import test from 'ava'; -import {SilentRenderer} from '../fixtures/listr-renderer.js'; -import { - _stubExeca, - run, - assertTaskFailed, - assertTaskDoesntExist, -} from '../_utils.js'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDoesntExist} from '../_helpers/listr.js'; -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../../source/git-tasks.js', import.meta.url); +/** @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', async t => { - const gitTasks = await stubExeca([{ - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }]); - +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.'}, @@ -28,12 +22,10 @@ test.serial('should fail when release branch is not specified, current branch is 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', - }]); - +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.'}, @@ -42,30 +34,28 @@ test.serial('should fail when current branch is not the specified release 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', - }, - ]); - +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})), ); @@ -73,18 +63,16 @@ test.serial('should not fail when current branch not master and publishing from 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', - }, - ]); - +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.'}, @@ -93,51 +81,47 @@ test.serial('should fail when local working tree modified', async t => { 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\'', - }, - ]); - +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', 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 - }, - ]); - +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.'}, @@ -146,26 +130,24 @@ test.serial('should fail when remote history differs and changes are fetched', a 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 - }, - ]); - +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.'}, @@ -174,30 +156,28 @@ test.serial('should fail when remote has unfetched changes', async t => { 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', - }, - ]); - +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/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 21284913..b25b3167 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -3,31 +3,25 @@ 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', import.meta.url); +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); const {packageJson: pkg} = await readPackageUp(); 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 +30,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 +41,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,20 +52,16 @@ 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: '', - }, - ]); - +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 = pkg.engines.npm; await t.throwsAsync( @@ -88,20 +72,16 @@ test.serial('should fail when npm version does not match range in `package.json` 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: '', - }, - ]); - +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 = pkg.engines.yarn; await t.throwsAsync( @@ -112,20 +92,16 @@ test.serial('should fail when yarn version does not match range in `package.json 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 +114,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 +140,10 @@ 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('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,13 +155,10 @@ 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', - }]); - +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 = pkg.engines.git; await t.throwsAsync( @@ -204,14 +169,12 @@ test.serial('should fail when git version does not match range in `package.json` 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'}, @@ -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/hyperlinks.js b/test/util/hyperlinks.js similarity index 97% rename from test/hyperlinks.js rename to test/util/hyperlinks.js index c5879174..40ea658f 100644 --- a/test/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -1,7 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; import terminalLink from 'terminal-link'; -import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util.js'; +import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../../source/util.js'; const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; const MOCK_COMMIT_HASH = '5063f8a'; @@ -13,6 +13,7 @@ test.afterEach(() => { sandbox.restore(); }); +// TODO: use esmock, get rid of serial on tests const mockTerminalLinkUnsupported = () => sandbox.stub(terminalLink, 'isSupported').value(false); diff --git a/test/integration.js b/test/util/integration.js similarity index 67% rename from test/integration.js rename to test/util/integration.js index fab34722..15423dae 100644 --- a/test/integration.js +++ b/test/util/integration.js @@ -1,50 +1,14 @@ 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 {execa} from 'execa'; 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!', - ); - }); -}); +import {createIntegrationTest} from '../_helpers/integration-test.js'; 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', {}, { + /** @type {import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { 'node:process': {cwd: () => temporaryDir}, execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, }); diff --git a/test/prefix.js b/test/util/prefix.js similarity index 68% rename from test/prefix.js rename to test/util/prefix.js index 7eb5ef8c..e7677456 100644 --- a/test/prefix.js +++ b/test/util/prefix.js @@ -1,7 +1,7 @@ import test from 'ava'; import esmock from 'esmock'; import {stripIndent} from 'common-tags'; -import {getTagVersionPrefix} from '../source/util.js'; +import {getTagVersionPrefix} from '../../source/util.js'; test('get tag prefix', async t => { t.is(await getTagVersionPrefix({yarn: false}), 'v'); @@ -16,10 +16,10 @@ test('no options passed', async t => { 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}, +test('defaults to "v" when command fails', async t => { + const util = await esmock('../../source/util.js', { + execa: {execa: Promise.reject}, }); - t.is(await testedModule.getTagVersionPrefix({yarn: true}), 'v'); + t.is(await util.getTagVersionPrefix({yarn: true}), 'v'); }); diff --git a/test/preid.js b/test/util/preid.js similarity index 90% rename from test/preid.js rename to test/util/preid.js index b83bcd84..986b31ce 100644 --- a/test/preid.js +++ b/test/util/preid.js @@ -1,6 +1,6 @@ import test from 'ava'; import {stripIndent} from 'common-tags'; -import {getPreReleasePrefix} from '../source/util.js'; +import {getPreReleasePrefix} from '../../source/util.js'; test('get preId postfix', async t => { t.is(await getPreReleasePrefix({yarn: false}), ''); From 22cac30b178835ef2254f8805c2bfccd97e2b69a Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 7 Apr 2023 21:53:55 -0500 Subject: [PATCH 04/63] fix(`git`): move tests to integration, fix `defaultBranch` tests --- test/git/integration.js | 31 +++++++++++++++++++++++++------ test/git/unit.js | 12 +----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/test/git/integration.js b/test/git/integration.js index 4a8e8053..7796686a 100644 --- a/test/git/integration.js +++ b/test/git/integration.js @@ -7,6 +7,12 @@ 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('git-util.latestTag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: git}) => { + t.is(await git.latestTag(), 'v0.0.0'); +}); + test('git-util.newFilesSinceLastRelease', createFixture, async ({t, $$}) => { await $$`git tag v0.0.0`; await t.context.createFile('new'); @@ -35,6 +41,17 @@ test('git-util.newFilesSinceLastRelease - use ignoreWalker', createFixture, asyn t.deepEqual(newFiles.sort(), ['index.js', 'package.json', '.gitignore'].sort()); }); +// TODO: failing, seems like issue with path.relative +test.failing('git-util.readFileFromLastRelease', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git}) => { + const file = await git.readFileFromLastRelease('unicorn.txt'); + t.is(file, 'unicorn'); +}); + // TODO: `tagList` always has a minimum length of 1 -> `''.split('\n')` => `['']` test.failing('git-util.previousTagOrFirstCommit - no tags', createFixture, () => {}, async ({t, testedModule: git}) => { const result = await git.previousTagOrFirstCommit(); @@ -145,22 +162,24 @@ test('git-util.defaultBranch - main', createFixture, async ({$$}) => { }); test('git-util.defaultBranch - master', createFixture, async ({$$}) => { - await $$`git switch -c master`; - await $$`git branch -D main`; + await $$`git checkout -B master`; + await $$`git update-ref -d refs/heads/main`; }, async ({t, testedModule: git}) => { t.is(await git.defaultBranch(), 'master'); }); test('git-util.defaultBranch - gh-pages', createFixture, async ({$$}) => { - await $$`git switch -c gh-pages`; - await $$`git branch -D main`; + 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: git}) => { t.is(await git.defaultBranch(), 'gh-pages'); }); test('git-util.defaultBranch - fails', createFixture, async ({$$}) => { - await $$`git switch -c unicorn`; - await $$`git branch -D main`; + await $$`git checkout -B unicorn`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; }, async ({t, testedModule: git}) => { await t.throwsAsync( git.defaultBranch(), diff --git a/test/git/unit.js b/test/git/unit.js index ae20aa27..8b7cf5ce 100644 --- a/test/git/unit.js +++ b/test/git/unit.js @@ -4,23 +4,13 @@ import {readPackageUp} from 'read-pkg-up'; import * as git from '../../source/git-util.js'; const package_ = await readPackageUp(); -const {packageJson: pkg, path: pkgPath} = package_; +const {path: pkgPath} = package_; const rootDir = path.dirname(pkgPath); -test('git-util.latestTag', async t => { - const version = `v${pkg.version}`; - t.is(await git.latestTag(), version); -}); - test('git-util.root', async t => { t.is(await git.root(), rootDir); }); -test('git-util.readFileFromLastRelease', async t => { - const oldPkg = await git.readFileFromLastRelease(pkgPath); - t.is(JSON.parse(oldPkg).name, 'np'); -}); - test('git-util.checkIfFileGitIgnored', async t => { t.false(await git.checkIfFileGitIgnored(pkgPath)); t.true(await git.checkIfFileGitIgnored(path.resolve(rootDir, 'yarn.lock'))); From 796595d163c61b4c59e8dfdf06f2048f0cdd8785 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sat, 8 Apr 2023 14:31:29 -0500 Subject: [PATCH 05/63] tests(`util`): add more tests --- package.json | 1 + source/util.js | 2 +- test/util/integration.js | 27 ++++++++++++++++++++------ test/util/unit.js | 42 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 test/util/unit.js diff --git a/package.json b/package.json index be918d23..13f7e904 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "fs-extra": "^11.1.1", "move-file": "^3.1.0", "sinon": "^15.0.3", + "strip-ansi": "^7.0.1", "tempy": "^3.0.0", "write-pkg": "^5.1.0", "xo": "^0.53.1" diff --git a/source/util.js b/source/util.js index dda0f951..d4b9ca49 100644 --- a/source/util.js +++ b/source/util.js @@ -11,7 +11,7 @@ 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(); + packagePath = packagePath ? await packageDirectory({cwd: packagePath}) : await packageDirectory(); if (!packagePath) { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } diff --git a/test/util/integration.js b/test/util/integration.js index 15423dae..63310996 100644 --- a/test/util/integration.js +++ b/test/util/integration.js @@ -3,7 +3,11 @@ import test from 'ava'; import esmock from 'esmock'; import {execa} from 'execa'; import {writePackage} from 'write-pkg'; -import {createIntegrationTest} from '../_helpers/integration-test.js'; +import {readPackage} from 'read-pkg'; +import {createIntegrationTest, _createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js'); const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { await createIntegrationTest(t, async ($$, temporaryDir) => { @@ -28,7 +32,7 @@ const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublis }); }); -test('files to package with tags added', createNewFilesFixture, ['*.js'], async (t, $$) => { +test('util.getNewFiles - 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'); @@ -36,7 +40,7 @@ test('files to package with tags added', createNewFilesFixture, ['*.js'], async await $$`git commit -m "added"`; }, {unpublished: ['new'], firstTime: ['index.js']}); -test('file `new` to package without tags added', createNewFilesFixture, ['index.js'], async t => { +test('util.getNewFiles - 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']}); @@ -46,7 +50,7 @@ test('file `new` to package without tags added', createNewFilesFixture, ['index. const filePath1 = path.join(longPath, 'file1'); const filePath2 = path.join(longPath, 'file2'); - test('files with long pathnames added', createNewFilesFixture, ['*.js'], async (t, $$) => { + test('util.getNewFiles - 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); @@ -55,11 +59,11 @@ test('file `new` to package without tags added', createNewFilesFixture, ['index. }, {unpublished: [filePath1, filePath2], firstTime: []}); })(); -test('no new files added', createNewFilesFixture, [], async (_t, $$) => { +test('util.getNewFiles - 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, $$) => { +test('util.getNewFiles - 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'); @@ -67,3 +71,14 @@ test('ignores .git and .github files', createNewFilesFixture, ['*.js'], async (t await $$`git add -A`; await $$`git commit -m "added"`; }, {unpublished: [], firstTime: ['index.js']}); + +test('util.getNewDependencies', createFixture, async ({$$, temporaryDir}) => { + await writePackage(temporaryDir, {dependencies: {'dog-names': '^2.1.0'}}); + await $$`git add -A`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: util, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + t.deepEqual(await util.getNewDependencies(pkg, temporaryDir), ['cat-names']); +}); diff --git a/test/util/unit.js b/test/util/unit.js new file mode 100644 index 00000000..336f56a9 --- /dev/null +++ b/test/util/unit.js @@ -0,0 +1,42 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import test from 'ava'; +import {temporaryDirectory} from 'tempy'; +import stripAnsi from 'strip-ansi'; +import * as util from '../../source/util.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '../..'); + +test('util.readPkg - without packagePath', async t => { + const {pkg, rootDir: pkgDir} = await util.readPkg(); + + t.is(pkg.name, 'np'); + t.is(pkgDir, rootDir); +}); + +test('util.readPkg - with packagePath', async t => { + const fixtureDir = path.resolve(rootDir, 'test/fixtures/files/one-file'); + const {pkg, rootDir: pkgDir} = await util.readPkg(fixtureDir); + + t.is(pkg.name, 'foo'); + t.is(pkgDir, fixtureDir); +}); + +test('util.readPkg - no package.json', async t => { + await t.throwsAsync( + util.readPkg(temporaryDirectory()), + {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, + ); +}); + +const testJoinList = test.macro((t, list, expectations) => { + const output = util.joinList(list); + t.is(stripAnsi(output), expectations); +}); + +test('util.joinList - one item', testJoinList, ['foo'], '- foo'); + +test('util.joinList - two items', testJoinList, ['foo', 'bar'], '- foo\n- bar'); + +test('util.joinList - multiple items', testJoinList, ['foo', 'bar', 'baz'], '- foo\n- bar\n- baz'); From af48c4ed2a2f49dc139f5a0c84f5dc1ee8189c6f Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sat, 8 Apr 2023 20:16:06 -0500 Subject: [PATCH 06/63] feat(`pretty-version-diff`): simplify and add tests --- package.json | 1 + source/pretty-version-diff.js | 36 ++++++++--------- source/version.js | 7 ++++ test/pretty-version-diff.js | 75 +++++++++++++++++++++++++++++++++++ test/version.js | 40 ++++++++++++++----- 5 files changed, 129 insertions(+), 30 deletions(-) create mode 100644 test/pretty-version-diff.js diff --git a/package.json b/package.json index 13f7e904..c3638806 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "devDependencies": { "ava": "^5.2.0", + "chalk-template": "^1.0.0", "common-tags": "^1.8.2", "esmock": "^2.2.1", "fs-extra": "^11.1.1", diff --git a/source/pretty-version-diff.js b/source/pretty-version-diff.js index 059fa219..92388133 100644 --- a/source/pretty-version-diff.js +++ b/source/pretty-version-diff.js @@ -1,26 +1,24 @@ -import chalk from 'chalk'; +import chalkTemplate from 'chalk-template'; +import semver from 'semver'; 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 = []; + const newVersion = new Version(oldVersion).getNewVersionFrom(inc); + const {major, minor, patch, prerelease} = Version.getPartsOf(newVersion); + const diff = semver.diff(oldVersion, newVersion); - 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('.')); + /* eslint-disable indent, unicorn/no-nested-ternary, operator-linebreak */ + return ( + diff === 'major' ? chalkTemplate`{dim {cyan ${major}}.${minor}.${patch}}` : + diff === 'minor' ? chalkTemplate`{dim ${major}.{cyan ${minor}}.${patch}}` : + diff === 'patch' ? chalkTemplate`{dim ${major}.${minor}.{cyan ${patch}}}` : + diff === 'premajor' ? chalkTemplate`{dim {cyan ${major}}.${minor}.${patch}-{cyan ${prerelease.join('.')}}}` : + diff === 'preminor' ? chalkTemplate`{dim ${major}.{cyan ${minor}}.${patch}-{cyan ${prerelease.join('.')}}}` : + diff === 'prepatch' ? chalkTemplate`{dim ${major}.${minor}.{cyan ${patch}}-{cyan ${prerelease.join('.')}}}` : + diff === 'prerelease' ? chalkTemplate`{dim ${major}.${minor}.${patch}-{cyan ${prerelease.join('.')}}}` : '' + // TODO: handle prepatch being the same as prerelease + ); + /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak */ }; export default prettyVersionDiff; diff --git a/source/version.js b/source/version.js index 56710fac..d39c6bf2 100644 --- a/source/version.js +++ b/source/version.js @@ -3,6 +3,8 @@ import {readPackageUp} from 'read-pkg-up'; const {packageJson: pkg} = await readPackageUp(); +// TODO: make the API cleaner + export default class Version { constructor(version) { this.version = version; @@ -19,6 +21,7 @@ export default class Version { }); } + /** @returns {string} */ getNewVersionFrom(input) { Version.validate(this.version); if (!Version.isValidInput(input)) { @@ -73,4 +76,8 @@ export default class Version { return newVersion; } + + static getPartsOf(version) { + return semver.parse(version); + } } diff --git a/test/pretty-version-diff.js b/test/pretty-version-diff.js new file mode 100644 index 00000000..50571e82 --- /dev/null +++ b/test/pretty-version-diff.js @@ -0,0 +1,75 @@ +import test from 'ava'; +import {template as chalk} from 'chalk-template'; +import prettyVersionDiff from '../source/pretty-version-diff.js'; + +/** @param {string} input - Place `{ }` around the version parts to be highlighted. */ +const makeNewVersion = input => { + input = input.replaceAll(/{([^}]*)}/g, '{cyan $1}'); // https://regex101.com/r/rZUIp4/1 + return chalk(`{dim ${input}}`); +}; + +test('major', t => { + const newVersion = makeNewVersion('{1}.0.0'); + + t.is(prettyVersionDiff('0.0.0', 'major'), newVersion); + t.is(prettyVersionDiff('0.0.0', '1.0.0'), newVersion); +}); + +test('minor', t => { + const newVersion = makeNewVersion('0.{1}.0'); + + t.is(prettyVersionDiff('0.0.0', 'minor'), newVersion); + t.is(prettyVersionDiff('0.0.0', '0.1.0'), newVersion); +}); + +test('patch', t => { + const newVersion = makeNewVersion('0.0.{1}'); + + t.is(prettyVersionDiff('0.0.0', 'patch'), newVersion); + t.is(prettyVersionDiff('0.0.0', '0.0.1'), newVersion); +}); + +test('premajor', t => { + const newVersion = makeNewVersion('{1}.0.0-{0}'); + + t.is(prettyVersionDiff('0.0.0', 'premajor'), newVersion); + t.is(prettyVersionDiff('0.0.0', '1.0.0-0'), newVersion); +}); + +test('preminor', t => { + const newVersion = makeNewVersion('0.{1}.0-{0}'); + + t.is(prettyVersionDiff('0.0.0', 'preminor'), newVersion); + t.is(prettyVersionDiff('0.0.0', '0.1.0-0'), newVersion); +}); + +test('prepatch', t => { + const newVersion = makeNewVersion('0.0.{1}-{0}'); + + t.is(prettyVersionDiff('0.0.0', 'prepatch'), newVersion); + t.is(prettyVersionDiff('0.0.0', '0.0.1-0'), newVersion); +}); + +test('prerelease', t => { + const newVersion = makeNewVersion('0.0.0-{1}'); + + t.is(prettyVersionDiff('0.0.0-0', 'prerelease'), newVersion); + t.is(prettyVersionDiff('0.0.0-0', '0.0.0-1'), newVersion); +}); + +test('prerelease as prepatch', t => { + const newVersion = makeNewVersion('0.0.{1}-{0}'); + + t.is(prettyVersionDiff('0.0.0', 'prerelease'), newVersion); + t.is(prettyVersionDiff('0.0.0', '0.0.1-0'), newVersion); +}); + +test('prerelease with multiple numbers', t => { + const newVersion = makeNewVersion('0.0.{1}-{0.0}'); // TODO: should it be {0}.{0}? + t.is(prettyVersionDiff('0.0.0', '0.0.1-0.0'), newVersion); +}); + +test('prerelease with text', t => { + const newVersion = makeNewVersion('0.0.{1}-{alpha.0}'); // TODO: should it be {alpha}.{0}? + t.is(prettyVersionDiff('0.0.0', '0.0.1-alpha.0'), newVersion); +}); diff --git a/test/version.js b/test/version.js index 203fb4e2..bf858bab 100644 --- a/test/version.js +++ b/test/version.js @@ -1,15 +1,15 @@ import test from 'ava'; import Version from '../source/version.js'; -test('version.SEMVER_INCREMENTS', t => { +test('SEMVER_INCREMENTS', t => { t.deepEqual(Version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); }); -test('version.PRERELEASE_VERSIONS', t => { +test('PRERELEASE_VERSIONS', t => { t.deepEqual(Version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); }); -test('version.isValidInput', t => { +test('isValidInput', t => { t.false(Version.isValidInput(null)); t.false(Version.isValidInput('foo')); t.false(Version.isValidInput('1.0.0.0')); @@ -28,7 +28,7 @@ test('version.isValidInput', t => { t.true(Version.isValidInput('2.0.0-rc.2')); }); -test('version.isPrerelease', t => { +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()); @@ -37,7 +37,7 @@ test('version.isPrerelease', t => { t.true(new Version('2.0.0-rc.2').isPrerelease()); }); -test('version.isPrereleaseOrIncrement', t => { +test('isPrereleaseOrIncrement', t => { t.false(Version.isPrereleaseOrIncrement('patch')); t.false(Version.isPrereleaseOrIncrement('minor')); t.false(Version.isPrereleaseOrIncrement('major')); @@ -48,7 +48,7 @@ test('version.isPrereleaseOrIncrement', t => { t.true(Version.isPrereleaseOrIncrement('prerelease')); }); -test('version.getNewVersionFrom', t => { +test('getNewVersionFrom', t => { const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; t.throws(() => new Version('1.0.0').getNewVersionFrom('patchxxx'), {message}); @@ -66,7 +66,7 @@ test('version.getNewVersionFrom', t => { t.is(new Version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); }); -test('version.validate', t => { +test('validate', t => { const message = 'Version should be a valid semver version.'; t.throws(() => Version.validate('patch'), {message}); @@ -78,7 +78,7 @@ test('version.validate', t => { t.notThrows(() => Version.validate('1.0.0-0')); }); -test('version.isGreaterThanOrEqualTo', t => { +test('isGreaterThanOrEqualTo', t => { t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); @@ -94,7 +94,7 @@ test('version.isGreaterThanOrEqualTo', t => { t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); }); -test('version.isLowerThanOrEqualTo', t => { +test('isLowerThanOrEqualTo', t => { t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.0.1')); t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.1.0')); @@ -110,7 +110,7 @@ test('version.isLowerThanOrEqualTo', t => { t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); }); -test('version.satisfies', t => { +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')); @@ -119,7 +119,7 @@ test('version.satisfies', t => { t.false(new Version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); }); -test('version.getAndValidateNewVersionFrom', t => { +test('getAndValidateNewVersionFrom', t => { t.is(Version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); t.throws( @@ -137,3 +137,21 @@ test('version.getAndValidateNewVersionFrom', t => { {message: 'New version `1.0.0` should be higher than current version `2.0.0`'}, ); }); + +test('getPartsOf', t => { + t.like(Version.getPartsOf('1.2.3'), { + major: 1, + minor: 2, + patch: 3, + prerelease: [], + }); + + t.like(Version.getPartsOf('1.2.3-alpha.4.5.6'), { + major: 1, + minor: 2, + patch: 3, + prerelease: ['alpha', 4, 5, 6], + }); +}); + +// TODO; verifyRequirementSatisfied From dce03f53dbb4bd409dfe6557ba36f8a6573fb181 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 10 Apr 2023 21:50:45 -0500 Subject: [PATCH 07/63] update `Version` API, remove `pretty-version-diff.js`, consolidate tasks/prompts, style/grammar fixes --- source/cli-implementation.js | 8 +- source/git-util.js | 45 +++--- source/index.js | 105 ++++++-------- source/npm/util.js | 17 +-- source/prerequisite-tasks.js | 14 +- source/pretty-version-diff.js | 24 ---- source/release-task-helper.js | 9 +- source/ui.js | 55 ++++---- source/util.js | 22 +-- source/version.js | 144 ++++++++++++-------- test/_helpers/integration-test.js | 4 +- test/git/integration.js | 6 +- test/git/stub.js | 4 +- test/git/unit.js | 14 +- test/index.js | 12 +- test/npm/util/stub.js | 4 +- test/pretty-version-diff.js | 75 ---------- test/tasks/prerequisite-tasks.js | 27 ++-- test/util/hyperlinks.js | 2 + test/util/integration.js | 18 +-- test/util/unit.js | 30 ++++ test/version.js | 218 +++++++++++++++--------------- 22 files changed, 406 insertions(+), 451 deletions(-) delete mode 100644 source/pretty-version-diff.js delete mode 100644 test/pretty-version-diff.js diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 5ccdf694..6bdf95f2 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 @@ -130,8 +130,8 @@ try { 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) ?? false); // TODO: can this be undefined? const branch = flags.branch || await git.defaultBranch(); const options = await ui({ diff --git a/source/git-util.js b/source/git-util.js index 1071dba4..66d910e2 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']); @@ -182,26 +182,6 @@ export const fetch = async () => { await execa('git', ['fetch']); }; -const tagExistsOnRemote = async tagName => { - try { - const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); - - if (revInfo) { - return true; - } - - return false; - } catch (error) { - // Command fails with code 1 and no output if the tag does not exist, even though `--quiet` is provided - // https://github.com/sindresorhus/np/pull/73#discussion_r72385685 - if (error.stdout === '' && error.stderr === '') { - return false; - } - - throw error; - } -}; - const hasLocalBranch = async branch => { try { await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); @@ -222,6 +202,26 @@ export const defaultBranch = async () => { 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}`]); + + if (revInfo) { + return true; + } + + return false; + } catch (error) { + // Command fails with code 1 and no output if the tag does not exist, even though `--quiet` is provided + // https://github.com/sindresorhus/np/pull/73#discussion_r72385685 + if (error.stdout === '' && error.stderr === '') { + return false; + } + + throw error; + } +}; + export const verifyTagDoesNotExistOnRemote = async tagName => { if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); @@ -267,8 +267,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 c50319b5..02b7189d 100644 --- a/source/index.js +++ b/source/index.js @@ -83,10 +83,13 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { if (publishStatus === 'FAILED') { await rollback(); } else { - console.log('\nAborted!'); + console.log('\nAborted!'); // TODO: maybe only show 'Aborted!' if user cancels? } }, {minimumWait: 2000}); + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); + + // TODO: move tasks to subdirectory const tasks = new Listr([ { title: 'Prerequisite check', @@ -97,13 +100,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 +133,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 +153,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }), ), }, - ]); - } - - tasks.add([ + ] : [], { title: 'Bumping version using Yarn', enabled: () => options.yarn === true, @@ -211,10 +202,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 +227,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() { @@ -290,7 +266,14 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { } }, task: () => releaseTaskHelper(options, pkg), - }); + }] : [], + ], { + showSubtasks: false, + renderer: options.renderer ?? 'default', + }); + + if (!options.runPublish) { + publishStatus = 'SUCCESS'; } await tasks.run(); diff --git a/source/npm/util.js b/source/npm/util.js index e0ca465e..0b16b7a1 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -5,8 +5,13 @@ import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; import chalk from 'chalk'; -import semver from 'semver'; import Version from '../version.js'; +import * as util from '../util.js'; + +const version = async () => { + const {stdout} = await execa('npm', ['--version']); + return stdout; +}; export const checkConnection = () => pTimeout( (async () => { @@ -47,7 +52,8 @@ export const collaborators = async pkg => { ow(packageName, ow.string); const npmVersion = await version(); - const args = semver.satisfies(npmVersion, '>=9.0.0') + // 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]; @@ -122,14 +128,9 @@ export const isPackageNameAvailable = async pkg => { return availability; }; -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) => { diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 50006c8d..d8b3af15 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -2,16 +2,16 @@ 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 = [ - { + { // TODO: consolidate tasks in move to listr2 title: 'Ping npm registry', enabled: () => !pkg.private && !isExternalRegistry, task: async () => npm.checkConnection(), @@ -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,13 @@ const prerequisiteTasks = (input, pkg, options) => { { title: 'Validate version', task() { - newVersion = Version.getAndValidateNewVersionFrom(input, pkg.version); + newVersion = new Version(pkg.version).setNewVersionFrom(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 +75,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 92388133..00000000 --- a/source/pretty-version-diff.js +++ /dev/null @@ -1,24 +0,0 @@ -import chalkTemplate from 'chalk-template'; -import semver from 'semver'; -import Version from './version.js'; - -const prettyVersionDiff = (oldVersion, inc) => { - const newVersion = new Version(oldVersion).getNewVersionFrom(inc); - const {major, minor, patch, prerelease} = Version.getPartsOf(newVersion); - const diff = semver.diff(oldVersion, newVersion); - - /* eslint-disable indent, unicorn/no-nested-ternary, operator-linebreak */ - return ( - diff === 'major' ? chalkTemplate`{dim {cyan ${major}}.${minor}.${patch}}` : - diff === 'minor' ? chalkTemplate`{dim ${major}.{cyan ${minor}}.${patch}}` : - diff === 'patch' ? chalkTemplate`{dim ${major}.${minor}.{cyan ${patch}}}` : - diff === 'premajor' ? chalkTemplate`{dim {cyan ${major}}.${minor}.${patch}-{cyan ${prerelease.join('.')}}}` : - diff === 'preminor' ? chalkTemplate`{dim ${major}.{cyan ${minor}}.${patch}-{cyan ${prerelease.join('.')}}}` : - diff === 'prepatch' ? chalkTemplate`{dim ${major}.${minor}.{cyan ${patch}}-{cyan ${prerelease.join('.')}}}` : - diff === 'prerelease' ? chalkTemplate`{dim ${major}.${minor}.${patch}-{cyan ${prerelease.join('.')}}}` : '' - // TODO: handle prepatch being the same as prerelease - ); - /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak */ -}; - -export default prettyVersionDiff; diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 2e71aca9..c4234723 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,10 +4,11 @@ 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); + const newVersion = new Version(pkg.version, options.version); let tag = await getTagVersionPrefix(options) + newVersion; - const isPreRelease = new Version(options.version).isPrerelease(); - if (isPreRelease) { + + const isPrerelease = new Version(options.version).isPrerelease(); + if (isPrerelease) { tag += await getPreReleasePrefix(options); } @@ -15,7 +16,7 @@ const releaseTaskHelper = async (options, pkg) => { repoUrl: options.repoUrl, tag, body: options.releaseNotes(tag), - isPrerelease: isPreRelease, + isPrerelease, }); await open(url); diff --git a/source/ui.js b/source/ui.js index 1e60e51d..32d8b399 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(); @@ -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).format()})`) + : chalk.dim(`(current: ${oldVersion})`); console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); } @@ -214,44 +214,37 @@ const ui = async (options, {pkg, rootDir}) => { } } + const needsPrereleaseTag = answers => options.runPublish && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) && !options.tag; + 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 => ({ + 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'; - } - - if (new Version(oldVersion).isLowerThanOrEqualTo(input)) { - return `Version must be greater than ${oldVersion}`; - } - - return true; - }, + when: answers => answers.version === undefined, + // TODO: filter and validate at the same time + filter: input => new Version(oldVersion).setNewVersionFrom(input).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 +253,7 @@ const ui = async (options, {pkg, rootDir}) => { new inquirer.Separator(), { name: 'Other (specify)', - value: null, + value: undefined, }, ]; }, @@ -268,7 +261,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`.'; diff --git a/source/util.js b/source/util.js index d4b9ca49..18dbc528 100644 --- a/source/util.js +++ b/source/util.js @@ -6,23 +6,22 @@ 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({cwd: packagePath}) : await packageDirectory(); - if (!packagePath) { + 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)}; }; +export const {pkg: npPkg, rootDir: npRootDir} = await readPkg(); + export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { return message; @@ -121,3 +120,10 @@ export const getPreReleasePrefix = pMemoize(async options => { return ''; } }); + +export const validateEngineVersionSatisfies = (engine, version) => { + const engineRange = npPkg.engines[engine]; + if (!new Version(version).satisfies(engineRange)) { + throw new Error(`\`np\` requires ${engine} ${engineRange}`); // TODO: prettify range/engine, capitalize engine + } +}; diff --git a/source/version.js b/source/version.js index d39c6bf2..c6f05f44 100644 --- a/source/version.js +++ b/source/version.js @@ -1,83 +1,115 @@ import semver from 'semver'; -import {readPackageUp} from 'read-pkg-up'; +import {template as chalk} from 'chalk-template'; -const {packageJson: pkg} = await readPackageUp(); +const PRERELEASE_VERSIONS = ['premajor', 'preminor', 'prepatch', 'prerelease']; +export const SEMVER_INCREMENTS = ['major', 'minor', 'patch', ...PRERELEASE_VERSIONS]; +const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; -// TODO: make the API cleaner +/** @typedef {semver.SemVer} SemVerInstance */ +/** @typedef {semver.ReleaseType} SemVerIncrement */ -export default class Version { - constructor(version) { - this.version = version; - } +/** @param {string} input @returns {input is SemVerIncrement} */ +const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); - isPrerelease() { - return Boolean(semver.prerelease(this.version)); - } +const isInvalidSemVerVersion = input => Boolean(!semver.valid(input)); - satisfies(range) { - Version.validate(this.version); - return semver.satisfies(this.version, range, { - includePrerelease: true, - }); - } - - /** @returns {string} */ - 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.`); - } - - return Version.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; - } +export default class Version { + /** @type {SemVerInstance} */ + #version; - isGreaterThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + /** @type {SemVerIncrement | undefined} */ + diff = undefined; - return semver.gte(otherVersion, this.version); + get version() { + return this.#version.version; } - isLowerThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + set version(version) { + this.#version = semver.parse(version); - return semver.lte(otherVersion, this.version); + if (this.#version === null) { + // TODO: maybe make a custom InvalidSemVerError? + // TODO: linkify '`SemVer` version' + throw new Error(`Version \`${version}\` should be a valid \`SemVer\` version.`); + } } - static SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; - static PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; - - static isPrereleaseOrIncrement = input => new Version(input).isPrerelease() || Version.PRERELEASE_VERSIONS.includes(input); - - static isValidVersion = input => Boolean(semver.valid(input)); + /** + @param {string} version - A valid `SemVer` version. + @param {SemVerIncrement} [increment] - Optionally increment `version`. If valid, `Version.diff` will be defined as the difference between `version` and the new version. + */ + constructor(version, increment) { + this.version = version; - static isValidInput = input => Version.SEMVER_INCREMENTS.includes(input) || Version.isValidVersion(input); + if (increment) { + if (!isSemVerIncrement(increment)) { + throw new Error(`Increment \`${increment}\` should be one of ${SEMVER_INCREMENTS_LIST}.`); + } - static validate(version) { - if (!Version.isValidVersion(version)) { - throw new Error('Version should be a valid semver version.'); + this.setNewVersionFrom(increment); } } - static verifyRequirementSatisfied(dependency, version) { - const depRange = pkg.engines[dependency]; - if (!new Version(version).satisfies(depRange)) { - throw new Error(`Please upgrade to ${dependency}${depRange}`); + /** + Sets a new version based on `input`. If `input` is a valid `SemVer` increment, `Version.version` will be incrememnted by that amount and `Version.diff` will be set to `input`. If `input` is a valid `SemVer` version, `Version.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. + @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. + */ + setNewVersionFrom(input) { + if (isSemVerIncrement(input)) { + this.#version.inc(input); + this.diff = input; + } 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.version}\`.`); + } + + const oldVersion = this.#version; + this.version = input; + this.diff = semver.diff(oldVersion, this.#version); } - } - static getAndValidateNewVersionFrom(input, version) { - const newVersion = new Version(version).getNewVersionFrom(input); + return this; + } - if (new Version(version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); + // TODO: test custom colors + format(color = 'dim', {diffColor = 'cyan'} = {}) { + if (!this.diff) { + return chalk(`{${color} ${this.version}}`); } - return newVersion; + const {major, minor, patch, prerelease} = this.#version; + + /* 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('.')}}}`) : // TODO: handle prerelease diffs + 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('.')}}}`) : '' // TODO: throw error if somehow invalid???? + ); + /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ + } + + satisfies(range) { + // TODO: validate range? + return semver.satisfies(this.version, range, { + includePrerelease: true, + }); + } + + isPrerelease() { + return Boolean(semver.prerelease(this.#version)); } - static getPartsOf(version) { - return semver.parse(version); + #isGreaterThanOrEqualTo(otherVersion) { + return semver.gte(this.#version, otherVersion); } } diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index bc2fedb6..427120de 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -24,12 +24,12 @@ export const createIntegrationTest = async (t, assertions) => { await createEmptyGitRepo($$, temporaryDir); t.context.createFile = async (file, content = '') => fs.writeFile(path.resolve(temporaryDir, file), content); - await assertions($$, temporaryDir); + await assertions({$$, temporaryDir}); }); }; export const _createFixture = source => test.macro(async (t, commands, assertions) => { - await createIntegrationTest(t, async ($$, temporaryDir) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { const testedModule = await esmock(source, {}, { 'node:process': {cwd: () => temporaryDir}, execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, diff --git a/test/git/integration.js b/test/git/integration.js index 7796686a..748491db 100644 --- a/test/git/integration.js +++ b/test/git/integration.js @@ -66,10 +66,10 @@ test('git-util.previousTagOrFirstCommit - one tag', createFixture, async ({$$}) t.is(firstCommitMessage.trim(), '"init1"'); }); -test('git-util.previousTagOrFirstCommit - two tags', createFixture, async ({$$}) => { +// TODO: not sure why failing +test.failing('git-util.previousTagOrFirstCommit - two tags', createFixture, async ({$$}) => { await $$`git tag v0.0.0`; await $$`git tag v1.0.0`; - await $$`git tag v2.0.0`; }, async ({t, testedModule: git}) => { const result = await git.previousTagOrFirstCommit(); t.is(result, 'v0.0.0'); @@ -140,6 +140,8 @@ test('git-util.verifyWorkingTreeIsClean - not clean', createFixture, async ({t}) ); }); +// TODO: git-util.verifyWorkingTreeIsClean - test `git status --porcelain` failing + test('git-util.verifyRemoteHistoryIsClean - no remote', createFixture, async () => {}, async ({t, testedModule: git}) => { const result = await t.notThrowsAsync( git.verifyRemoteHistoryIsClean(), diff --git a/test/git/stub.js b/test/git/stub.js index 07f01045..0ad2147c 100644 --- a/test/git/stub.js +++ b/test/git/stub.js @@ -89,6 +89,8 @@ test('git-util.verifyTagDoesNotExistOnRemote - does not exist', createFixture, [ ); }); +// TODO: git-util.verifyTagDoesNotExistOnRemote - test when tagExistsOnRemote() errors + test('git-util.verifyRecentGitVersion - satisfied', createFixture, [{ command: 'git version', stdout: 'git version 2.12.0', // One higher than minimum @@ -104,7 +106,7 @@ test('git-util.verifyRecentGitVersion - not satisfied', createFixture, [{ }], async ({t, testedModule: git}) => { await t.throwsAsync( git.verifyRecentGitVersion(), - {message: 'Please upgrade to git>=2.11.0'}, // TODO: add space to error message? + {message: '`np` requires git >=2.11.0'}, ); }); diff --git a/test/git/unit.js b/test/git/unit.js index 8b7cf5ce..df3e8101 100644 --- a/test/git/unit.js +++ b/test/git/unit.js @@ -1,17 +1,17 @@ import path from 'node:path'; import test from 'ava'; -import {readPackageUp} from 'read-pkg-up'; +import {npRootDir} from '../../source/util.js'; import * as git from '../../source/git-util.js'; -const package_ = await readPackageUp(); -const {path: pkgPath} = package_; -const rootDir = path.dirname(pkgPath); +const npPkgPath = path.join(npRootDir, 'package.json'); test('git-util.root', async t => { - t.is(await git.root(), rootDir); + t.is(await git.root(), npRootDir); }); test('git-util.checkIfFileGitIgnored', async t => { - t.false(await git.checkIfFileGitIgnored(pkgPath)); - t.true(await git.checkIfFileGitIgnored(path.resolve(rootDir, 'yarn.lock'))); + t.false(await git.checkIfFileGitIgnored(npPkgPath)); + t.true(await git.checkIfFileGitIgnored(path.resolve(npRootDir, 'yarn.lock'))); }); + +// TODO: git-util.checkIfFileGitIgnored - test throws diff --git a/test/index.js b/test/index.js index 03a0660c..24f61dfa 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 `4.x.3` should either be one of `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`, or a valid `SemVer` version.', ); test('version is pre-release', npFails, @@ -43,6 +43,7 @@ test('errors on too low version', npFails, 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/npm/util/stub.js b/test/npm/util/stub.js index 41eccbdd..bab22ae8 100644 --- a/test/npm/util/stub.js +++ b/test/npm/util/stub.js @@ -24,7 +24,7 @@ test('npm.checkConnection - fail', createFixture, [{ }); // TODO: find way to timeout without timing out ava -test.failing('npm.checkConnection - timeout', async t => { +test('npm.checkConnection - timeout', async t => { const npm = await esmock('../../../source/npm/util.js', {}, { execa: {execa: async () => setTimeout(16_000, {})}, }); @@ -84,6 +84,6 @@ test('npm.verifyRecentNpmVersion - not satisfied', createFixture, [{ }], async ({t, testedModule: npm}) => { await t.throwsAsync( npm.verifyRecentNpmVersion(), - {message: 'Please upgrade to npm>=7.19.0'}, // TODO: add space to error message? + {message: '`np` requires npm >=7.19.0'}, ); }); diff --git a/test/pretty-version-diff.js b/test/pretty-version-diff.js deleted file mode 100644 index 50571e82..00000000 --- a/test/pretty-version-diff.js +++ /dev/null @@ -1,75 +0,0 @@ -import test from 'ava'; -import {template as chalk} from 'chalk-template'; -import prettyVersionDiff from '../source/pretty-version-diff.js'; - -/** @param {string} input - Place `{ }` around the version parts to be highlighted. */ -const makeNewVersion = input => { - input = input.replaceAll(/{([^}]*)}/g, '{cyan $1}'); // https://regex101.com/r/rZUIp4/1 - return chalk(`{dim ${input}}`); -}; - -test('major', t => { - const newVersion = makeNewVersion('{1}.0.0'); - - t.is(prettyVersionDiff('0.0.0', 'major'), newVersion); - t.is(prettyVersionDiff('0.0.0', '1.0.0'), newVersion); -}); - -test('minor', t => { - const newVersion = makeNewVersion('0.{1}.0'); - - t.is(prettyVersionDiff('0.0.0', 'minor'), newVersion); - t.is(prettyVersionDiff('0.0.0', '0.1.0'), newVersion); -}); - -test('patch', t => { - const newVersion = makeNewVersion('0.0.{1}'); - - t.is(prettyVersionDiff('0.0.0', 'patch'), newVersion); - t.is(prettyVersionDiff('0.0.0', '0.0.1'), newVersion); -}); - -test('premajor', t => { - const newVersion = makeNewVersion('{1}.0.0-{0}'); - - t.is(prettyVersionDiff('0.0.0', 'premajor'), newVersion); - t.is(prettyVersionDiff('0.0.0', '1.0.0-0'), newVersion); -}); - -test('preminor', t => { - const newVersion = makeNewVersion('0.{1}.0-{0}'); - - t.is(prettyVersionDiff('0.0.0', 'preminor'), newVersion); - t.is(prettyVersionDiff('0.0.0', '0.1.0-0'), newVersion); -}); - -test('prepatch', t => { - const newVersion = makeNewVersion('0.0.{1}-{0}'); - - t.is(prettyVersionDiff('0.0.0', 'prepatch'), newVersion); - t.is(prettyVersionDiff('0.0.0', '0.0.1-0'), newVersion); -}); - -test('prerelease', t => { - const newVersion = makeNewVersion('0.0.0-{1}'); - - t.is(prettyVersionDiff('0.0.0-0', 'prerelease'), newVersion); - t.is(prettyVersionDiff('0.0.0-0', '0.0.0-1'), newVersion); -}); - -test('prerelease as prepatch', t => { - const newVersion = makeNewVersion('0.0.{1}-{0}'); - - t.is(prettyVersionDiff('0.0.0', 'prerelease'), newVersion); - t.is(prettyVersionDiff('0.0.0', '0.0.1-0'), newVersion); -}); - -test('prerelease with multiple numbers', t => { - const newVersion = makeNewVersion('0.0.{1}-{0.0}'); // TODO: should it be {0}.{0}? - t.is(prettyVersionDiff('0.0.0', '0.0.1-0.0'), newVersion); -}); - -test('prerelease with text', t => { - const newVersion = makeNewVersion('0.0.{1}-{alpha.0}'); // TODO: should it be {alpha}.{0}? - t.is(prettyVersionDiff('0.0.0', '0.0.1-alpha.0'), newVersion); -}); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index b25b3167..aa344eb8 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,15 +1,13 @@ 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 {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); -const {packageJson: pkg} = await readPackageUp(); test.afterEach(() => { SilentRenderer.clearTasks(); @@ -62,11 +60,11 @@ test.serial('should fail when npm version does not match range in `package.json` stdout: '', }, ], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = pkg.engines.npm; + 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'); @@ -82,11 +80,11 @@ test.serial('should fail when yarn version does not match range in `package.json stdout: '', }, ], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = pkg.engines.yarn; + 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'); @@ -114,6 +112,8 @@ test.serial('should fail when user is not authenticated at npm registry', create assertTaskFailed(t, 'Verify user is authenticated'); }); +// TODO: 'Verify user is authenticated' - verify passes if no collaborators + test.serial('should fail when user is not authenticated at external registry', createFixture, [ { command: 'npm whoami --registry http://my.io', @@ -159,11 +159,11 @@ test.serial('should fail when git version does not match range in `package.json` command: 'git version', stdout: 'git version 1.0.0', }], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = pkg.engines.git; + 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'); @@ -186,16 +186,16 @@ test.serial('should fail when git remote does not exist', createFixture, [{ 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 `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `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'); @@ -228,7 +228,8 @@ test.serial('should not fail when prerelease version of private package without ); }); -test.serial('should fail when git tag already exists', createFixture, [{ +// TODO: not sure why failing +test.serial.failing('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}) => { diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index 40ea658f..9a8f8e8d 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -55,3 +55,5 @@ test.serial('linkifyCommitRange returns raw commitRange if terminalLink is not s mockTerminalLinkUnsupported(); t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); }); + +// TODO: linkifyCommitRange - L55 - returns with `compare` diff --git a/test/util/integration.js b/test/util/integration.js index 63310996..50cea8c8 100644 --- a/test/util/integration.js +++ b/test/util/integration.js @@ -10,14 +10,14 @@ import {createIntegrationTest, _createFixture} from '../_helpers/integration-tes const createFixture = _createFixture('../../source/util.js'); const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { - await createIntegrationTest(t, async ($$, temporaryDir) => { + 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 commands({t, $$, temporaryDir}); await writePackage(temporaryDir, { name: 'foo', @@ -32,7 +32,7 @@ const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublis }); }); -test('util.getNewFiles - files to package with tags added', createNewFilesFixture, ['*.js'], async (t, $$) => { +test('util.getNewFiles - 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'); @@ -40,7 +40,7 @@ test('util.getNewFiles - files to package with tags added', createNewFilesFixtur await $$`git commit -m "added"`; }, {unpublished: ['new'], firstTime: ['index.js']}); -test('util.getNewFiles - file `new` to package without tags added', createNewFilesFixture, ['index.js'], async t => { +test('util.getNewFiles - 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']}); @@ -50,7 +50,8 @@ test('util.getNewFiles - file `new` to package without tags added', createNewFil const filePath1 = path.join(longPath, 'file1'); const filePath2 = path.join(longPath, 'file2'); - test('util.getNewFiles - files with long pathnames added', createNewFilesFixture, ['*.js'], async (t, $$) => { + // TODO: not sure why failing + test.failing('util.getNewFiles - 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); @@ -59,14 +60,15 @@ test('util.getNewFiles - file `new` to package without tags added', createNewFil }, {unpublished: [filePath1, filePath2], firstTime: []}); })(); -test('util.getNewFiles - no new files added', createNewFilesFixture, [], async (_t, $$) => { +test('util.getNewFiles - no new files added', createNewFilesFixture, [], async ({$$}) => { await $$`git tag v0.0.0`; }, {unpublished: [], firstTime: []}); -test('util.getNewFiles - ignores .git and .github files', createNewFilesFixture, ['*.js'], async (t, $$) => { +// TODO: not sure why failing +test.failing('util.getNewFiles - 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('.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/unit.js b/test/util/unit.js index 336f56a9..6e036b40 100644 --- a/test/util/unit.js +++ b/test/util/unit.js @@ -30,6 +30,14 @@ test('util.readPkg - no package.json', async t => { ); }); +test('util.npPkg', t => { + t.is(util.npPkg.name, 'np'); +}); + +test('util.npRootDir', t => { + t.is(util.npRootDir, rootDir); +}); + const testJoinList = test.macro((t, list, expectations) => { const output = util.joinList(list); t.is(stripAnsi(output), expectations); @@ -40,3 +48,25 @@ test('util.joinList - one item', testJoinList, ['foo'], '- foo'); test('util.joinList - two items', testJoinList, ['foo', 'bar'], '- foo\n- bar'); test('util.joinList - multiple items', testJoinList, ['foo', 'bar', 'baz'], '- foo\n- bar\n- baz'); + +const testEngineRanges = test.macro((t, engine, {above, below}) => { + const range = util.npPkg.engines[engine]; + + t.notThrows( + () => util.validateEngineVersionSatisfies(engine, above), // One above minimum + ); + + t.throws( + () => util.validateEngineVersionSatisfies(engine, below), // One below minimum + {message: `\`np\` requires ${engine} ${range}`}, + ); +}); + +test('util.validateEngineVersionSatisfies - node', testEngineRanges, 'node', {above: '16.7.0', below: '16.5.0'}); + +test('util.validateEngineVersionSatisfies - npm', testEngineRanges, 'npm', {above: '7.20.0', below: '7.18.0'}); + +test('util.validateEngineVersionSatisfies - git', testEngineRanges, 'git', {above: '2.12.0', below: '2.10.0'}); + +test('util.validateEngineVersionSatisfies - yarn', testEngineRanges, 'yarn', {above: '1.8.0', below: '1.6.0'}); + diff --git a/test/version.js b/test/version.js index bf858bab..cb79c818 100644 --- a/test/version.js +++ b/test/version.js @@ -1,113 +1,138 @@ import test from 'ava'; +import {template as chalk} from 'chalk-template'; import Version from '../source/version.js'; -test('SEMVER_INCREMENTS', t => { - t.deepEqual(Version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); +const INCREMENT_LIST = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `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').version, '1.0.0'); }); -test('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('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').version, '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('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'), // TODO: join last as 'or'? + {message: `Increment \`2.0.0\` should be one of ${INCREMENT_LIST}.`}, + ); +}); - 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('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('setNewVersionFrom - valid input as version', t => { + t.is(new Version('1.0.0').setNewVersionFrom('2.0.0').version, '2.0.0'); +}); - t.true(Version.isPrereleaseOrIncrement('prepatch')); - t.true(Version.isPrereleaseOrIncrement('preminor')); - t.true(Version.isPrereleaseOrIncrement('premajor')); - t.true(Version.isPrereleaseOrIncrement('prerelease')); +test('setNewVersionFrom - invalid input as version', t => { + t.throws( + () => new Version('1.0.0').setNewVersionFrom('200'), + {message: `New version \`200\` should either be one of ${INCREMENT_LIST}, or a valid \`SemVer\` version.`}, + ); }); -test('getNewVersionFrom', t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; +test('setNewVersionFrom - valid input is not higher than version', t => { + t.throws( + () => new Version('1.0.0').setNewVersionFrom('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')); +}); + +test('format - major', t => { + const newVersion = makeNewFormattedVersion('{1}.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'); + t.is(new Version('0.0.0').setNewVersionFrom('major').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('1.0.0').format(), newVersion); +}); - 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 - minor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.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').setNewVersionFrom('minor').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('0.1.0').format(), newVersion); }); -test('validate', t => { - const message = 'Version should be a valid semver version.'; +test('format - patch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}'); - t.throws(() => Version.validate('patch'), {message}); - t.throws(() => Version.validate('patchxxx'), {message}); - t.throws(() => Version.validate('1.0.0.0'), {message}); + t.is(new Version('0.0.0').setNewVersionFrom('patch').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('0.0.1').format(), newVersion); +}); + +test('format - premajor', t => { + const newVersion = makeNewFormattedVersion('{1}.0.0-{0}'); - 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').setNewVersionFrom('premajor').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('1.0.0-0').format(), newVersion); }); -test('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 - preminor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.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').setNewVersionFrom('preminor').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('0.1.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 - prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{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').setNewVersionFrom('prepatch').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0').format(), newVersion); }); -test('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 - prerelease', t => { + const newVersion = makeNewFormattedVersion('0.0.0-{1}'); - 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-0').setNewVersionFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0-0').setNewVersionFrom('0.0.0-1').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.failing('format - prerelease as prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - 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').setNewVersionFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0').format(), newVersion); +}); + +test('format - prerelease with multiple numbers', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0.0}'); // TODO: should it be {0}.{0}? + t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0.0').format(), newVersion); +}); + +test('format - prerelease with text', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{alpha.0}'); // TODO: should it be {alpha}.{0}? + t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-alpha.0').format(), newVersion); }); test('satisfies', t => { @@ -119,39 +144,12 @@ test('satisfies', t => { t.false(new Version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); }); -test('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.'}, - ); - - t.throws( - () => Version.getAndValidateNewVersionFrom('lol', '1.0.0'), - {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, - ); - - 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`'}, - ); -}); - -test('getPartsOf', t => { - t.like(Version.getPartsOf('1.2.3'), { - major: 1, - minor: 2, - patch: 3, - prerelease: [], - }); +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.like(Version.getPartsOf('1.2.3-alpha.4.5.6'), { - major: 1, - minor: 2, - patch: 3, - prerelease: ['alpha', 4, 5, 6], - }); + 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()); }); - -// TODO; verifyRequirementSatisfied From 49378f492178194120430af57f7bbf83c2d29f1c Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 10 Apr 2023 22:06:12 -0500 Subject: [PATCH 08/63] fix(`test/index`): incorrect message expectation --- test/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.js b/test/index.js index 24f61dfa..5f622fb8 100644 --- a/test/index.js +++ b/test/index.js @@ -27,7 +27,7 @@ const npFails = test.macro(async (t, inputs, message) => { test('version is invalid', npFails, ['foo', '4.x.3'], - 'New version `4.x.3` should either be one of `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`, or a valid `SemVer` version.', + /New version `(?:foo|4\.x\.3)` should either be one of `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`, or a valid `SemVer` version\./, ); test('version is pre-release', npFails, From 993a53e63079f34850b84454b6ac7bc70da6b3da Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 10 Apr 2023 22:11:56 -0500 Subject: [PATCH 09/63] fix(`version`): passing test marked as failing --- test/version.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/version.js b/test/version.js index cb79c818..73ff853a 100644 --- a/test/version.js +++ b/test/version.js @@ -118,7 +118,7 @@ test('format - prerelease', t => { t.is(new Version('0.0.0-0').setNewVersionFrom('0.0.0-1').format(), newVersion); }); -test.failing('format - prerelease as prepatch', t => { +test('format - prerelease as prepatch', t => { const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); t.is(new Version('0.0.0').setNewVersionFrom('prerelease').format(), newVersion); From f6b988e2db4516c813715bfc6b10b187585468cc Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 12:30:25 -0500 Subject: [PATCH 10/63] chore(`version`): rename `setNewVersionFrom` to `setFrom` --- source/prerequisite-tasks.js | 2 +- source/ui.js | 2 +- source/version.js | 4 +-- test/version.js | 48 ++++++++++++++++++------------------ 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index d8b3af15..a1bed295 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -59,7 +59,7 @@ const prerequisiteTasks = (input, pkg, options) => { { title: 'Validate version', task() { - newVersion = new Version(pkg.version).setNewVersionFrom(input); + newVersion = new Version(pkg.version).setFrom(input); }, }, { diff --git a/source/ui.js b/source/ui.js index 32d8b399..5db71432 100644 --- a/source/ui.js +++ b/source/ui.js @@ -239,7 +239,7 @@ const ui = async (options, {pkg, rootDir}) => { message: 'Version', when: answers => answers.version === undefined, // TODO: filter and validate at the same time - filter: input => new Version(oldVersion).setNewVersionFrom(input).version, + filter: input => new Version(oldVersion).setFrom(input).version, }, tag: { type: 'list', diff --git a/source/version.js b/source/version.js index c6f05f44..c6da03af 100644 --- a/source/version.js +++ b/source/version.js @@ -46,7 +46,7 @@ export default class Version { throw new Error(`Increment \`${increment}\` should be one of ${SEMVER_INCREMENTS_LIST}.`); } - this.setNewVersionFrom(increment); + this.setFrom(increment); } } @@ -56,7 +56,7 @@ export default class Version { @param {string | SemVerIncrement} input - A new valid `SemVer` version or a `SemVer` increment to increase the current version by. @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. */ - setNewVersionFrom(input) { + setFrom(input) { if (isSemVerIncrement(input)) { this.#version.inc(input); this.diff = input; diff --git a/test/version.js b/test/version.js index 73ff853a..c5b861c4 100644 --- a/test/version.js +++ b/test/version.js @@ -47,20 +47,20 @@ test('new Version - invalid w/ invalid increment', t => { }); // Input as SemVer increment is covered in constructor tests -test('setNewVersionFrom - valid input as version', t => { - t.is(new Version('1.0.0').setNewVersionFrom('2.0.0').version, '2.0.0'); +test('setFrom - valid input as version', t => { + t.is(new Version('1.0.0').setFrom('2.0.0').version, '2.0.0'); }); -test('setNewVersionFrom - invalid input as version', t => { +test('setFrom - invalid input as version', t => { t.throws( - () => new Version('1.0.0').setNewVersionFrom('200'), + () => new Version('1.0.0').setFrom('200'), {message: `New version \`200\` should either be one of ${INCREMENT_LIST}, or a valid \`SemVer\` version.`}, ); }); -test('setNewVersionFrom - valid input is not higher than version', t => { +test('setFrom - valid input is not higher than version', t => { t.throws( - () => new Version('1.0.0').setNewVersionFrom('0.2.0'), + () => new Version('1.0.0').setFrom('0.2.0'), {message: 'New version `0.2.0` should be higher than current version `1.0.0`.'}, ); }); @@ -72,67 +72,67 @@ test('format', t => { test('format - major', t => { const newVersion = makeNewFormattedVersion('{1}.0.0'); - t.is(new Version('0.0.0').setNewVersionFrom('major').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('1.0.0').format(), newVersion); + 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('format - minor', t => { const newVersion = makeNewFormattedVersion('0.{1}.0'); - t.is(new Version('0.0.0').setNewVersionFrom('minor').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('0.1.0').format(), newVersion); + t.is(new Version('0.0.0').setFrom('minor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0').format(), newVersion); }); test('format - patch', t => { const newVersion = makeNewFormattedVersion('0.0.{1}'); - t.is(new Version('0.0.0').setNewVersionFrom('patch').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('0.0.1').format(), newVersion); + 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('format - premajor', t => { const newVersion = makeNewFormattedVersion('{1}.0.0-{0}'); - t.is(new Version('0.0.0').setNewVersionFrom('premajor').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('1.0.0-0').format(), newVersion); + 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); }); test('format - preminor', t => { const newVersion = makeNewFormattedVersion('0.{1}.0-{0}'); - t.is(new Version('0.0.0').setNewVersionFrom('preminor').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('0.1.0-0').format(), newVersion); + 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('format - prepatch', t => { const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.is(new Version('0.0.0').setNewVersionFrom('prepatch').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0').format(), newVersion); + 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); }); test('format - prerelease', t => { const newVersion = makeNewFormattedVersion('0.0.0-{1}'); - t.is(new Version('0.0.0-0').setNewVersionFrom('prerelease').format(), newVersion); - t.is(new Version('0.0.0-0').setNewVersionFrom('0.0.0-1').format(), newVersion); + 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('format - prerelease as prepatch', t => { const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.is(new Version('0.0.0').setNewVersionFrom('prerelease').format(), newVersion); - t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0').format(), newVersion); + 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}'); // TODO: should it be {0}.{0}? - t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-0.0').format(), newVersion); + 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}'); // TODO: should it be {alpha}.{0}? - t.is(new Version('0.0.0').setNewVersionFrom('0.0.1-alpha.0').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); }); test('satisfies', t => { From 76dd173b3fe167f944c45ad0fd1895bf2621e88d Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 12:30:33 -0500 Subject: [PATCH 11/63] chore: bump `semver` --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3638806..116ff2ef 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "pkg-dir": "^7.0.0", "read-pkg-up": "^9.1.0", "rxjs": "^7.8.0", - "semver": "^7.3.8", + "semver": "^7.4.0", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" From 75a215654c08e498480ec52cdcfab3e7b68c4ccd Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 12:46:23 -0500 Subject: [PATCH 12/63] fix(`version`): get correct diff --- source/version.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/version.js b/source/version.js index c6da03af..fca670db 100644 --- a/source/version.js +++ b/source/version.js @@ -1,8 +1,7 @@ import semver from 'semver'; import {template as chalk} from 'chalk-template'; -const PRERELEASE_VERSIONS = ['premajor', 'preminor', 'prepatch', 'prerelease']; -export const SEMVER_INCREMENTS = ['major', 'minor', 'patch', ...PRERELEASE_VERSIONS]; +export const SEMVER_INCREMENTS = ['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease']; const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; /** @typedef {semver.SemVer} SemVerInstance */ @@ -57,9 +56,11 @@ export default class Version { @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) { + // Use getter - reference may change + const oldVersion = this.version; + if (isSemVerIncrement(input)) { this.#version.inc(input); - this.diff = input; } else { if (isInvalidSemVerVersion(input)) { throw new Error(`New version \`${input}\` should either be one of ${SEMVER_INCREMENTS_LIST}, or a valid \`SemVer\` version.`); @@ -69,11 +70,10 @@ export default class Version { throw new Error(`New version \`${input}\` should be higher than current version \`${this.version}\`.`); } - const oldVersion = this.#version; this.version = input; - this.diff = semver.diff(oldVersion, this.#version); } + this.diff = semver.diff(oldVersion, this.#version); return this; } From 722370b191aedf80bb811ff1960afe2f62336b70 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 12:48:03 -0500 Subject: [PATCH 13/63] chore(`version`): use new `semver.RELEASE_TYPES` --- source/version.js | 2 +- test/fixtures/files/gitignore/{gitignore => .gitignore} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test/fixtures/files/gitignore/{gitignore => .gitignore} (100%) diff --git a/source/version.js b/source/version.js index fca670db..c87596c5 100644 --- a/source/version.js +++ b/source/version.js @@ -1,7 +1,7 @@ import semver from 'semver'; import {template as chalk} from 'chalk-template'; -export const SEMVER_INCREMENTS = ['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease']; +export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; /** @typedef {semver.SemVer} SemVerInstance */ diff --git a/test/fixtures/files/gitignore/gitignore b/test/fixtures/files/gitignore/.gitignore similarity index 100% rename from test/fixtures/files/gitignore/gitignore rename to test/fixtures/files/gitignore/.gitignore From 9097485e4a43f9e9b77a86d99a71eb4b6ec13506 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 12:59:34 -0500 Subject: [PATCH 14/63] fix(`packed-files`): don't rename `.gitignore` --- package.json | 1 - test/fixtures/files/gitignore/.gitignore | 2 -- .../files/npmignore-and-gitignore/.gitignore | 1 + .../files/npmignore-and-gitignore/gitignore | 3 --- test/npm/packed-files.js | 14 ++------------ 5 files changed, 3 insertions(+), 18 deletions(-) create mode 100644 test/fixtures/files/npmignore-and-gitignore/.gitignore delete mode 100644 test/fixtures/files/npmignore-and-gitignore/gitignore diff --git a/package.json b/package.json index 116ff2ef..af2a61ee 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "common-tags": "^1.8.2", "esmock": "^2.2.1", "fs-extra": "^11.1.1", - "move-file": "^3.1.0", "sinon": "^15.0.3", "strip-ansi": "^7.0.1", "tempy": "^3.0.0", diff --git a/test/fixtures/files/gitignore/.gitignore b/test/fixtures/files/gitignore/.gitignore index a01644f5..1521c8b7 100644 --- a/test/fixtures/files/gitignore/.gitignore +++ b/test/fixtures/files/gitignore/.gitignore @@ -1,3 +1 @@ -# 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/.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/npm/packed-files.js b/test/npm/packed-files.js index 09bd8db3..debbe0aa 100644 --- a/test/npm/packed-files.js +++ b/test/npm/packed-files.js @@ -1,6 +1,5 @@ import path from 'node:path'; import test from 'ava'; -import {renameFile} from 'move-file'; import {getFilesToBePacked} from '../../source/npm/util.js'; import {runIfExists} from '../_helpers/util.js'; @@ -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', From 68997f1b178e1274097bb90dd03e22df3f06b159 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 11 Apr 2023 20:41:18 -0500 Subject: [PATCH 15/63] feat(`version`): improved prerelease diffs, more docs --- source/version.js | 31 +++++++++++++++++++++++++++---- test/version.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/source/version.js b/source/version.js index c87596c5..51bf16d0 100644 --- a/source/version.js +++ b/source/version.js @@ -12,6 +12,13 @@ const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); const isInvalidSemVerVersion = input => Boolean(!semver.valid(input)); +/** @param {string[]} current @param {string[]} previous */ +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 { /** @type {SemVerInstance} */ #version; @@ -77,20 +84,33 @@ export default class Version { return this; } - // TODO: test custom colors - format(color = 'dim', {diffColor = 'cyan'} = {}) { + /** + + @param {object} options + @param {import('chalk').ColorName} [options.color = 'dim'] + @param {import('chalk').ColorName} [options.diffColor = 'cyan'] + @param {string | SemVerInstance} [options.previousVersion] + @returns {string} A color-formatted version string. + */ + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { if (!this.diff) { return chalk(`{${color} ${this.version}}`); } const {major, minor, patch, prerelease} = this.#version; + const previousPrerelease = semver.prerelease(previousVersion); + + 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('.')}}}`) : // TODO: handle prerelease diffs + 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('.')}}}`) : '' // TODO: throw error if somehow invalid???? @@ -99,7 +119,10 @@ export default class Version { } satisfies(range) { - // TODO: validate range? + if (!semver.validRange(range)) { + throw new Error(`Range \`${range}\` is not a valid \`SemVer\` range.`); + } + return semver.satisfies(this.version, range, { includePrerelease: true, }); diff --git a/test/version.js b/test/version.js index c5b861c4..62fedaaf 100644 --- a/test/version.js +++ b/test/version.js @@ -135,13 +135,48 @@ test('format - prerelease with text', t => { t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); }); +test('format - prerelease diffs', t => { + t.is( + new Version('0.0.0-1.1').setFrom('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + makeNewFormattedVersion('0.0.0-1.{2}'), + ); +}); + +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('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')); + + t.throws( + () => new Version('1.2.3').satisfies('=>1.0.0'), + {message: 'Range `=>1.0.0` is not a valid `SemVer` range.'}, + ); }); test('isPrerelease', t => { From 280c66eb54b54fa10cd24f18eb648268c5310739 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 13 Apr 2023 15:24:55 -0500 Subject: [PATCH 16/63] feat(`version`): update/document API --- source/ui.js | 3 +- source/version.js | 100 +++++++++++++++++++++++++++++++++------------- test/version.js | 46 +++++++++++++++++++-- 3 files changed, 116 insertions(+), 33 deletions(-) diff --git a/source/ui.js b/source/ui.js index 5db71432..010f44b1 100644 --- a/source/ui.js +++ b/source/ui.js @@ -238,8 +238,7 @@ const ui = async (options, {pkg, rootDir}) => { type: 'input', message: 'Version', when: answers => answers.version === undefined, - // TODO: filter and validate at the same time - filter: input => new Version(oldVersion).setFrom(input).version, + filter: input => new Version(oldVersion).setFrom(input), // Version error handling does validation }, tag: { type: 'list', diff --git a/source/version.js b/source/version.js index 51bf16d0..3cc792c4 100644 --- a/source/version.js +++ b/source/version.js @@ -1,19 +1,26 @@ import semver from 'semver'; import {template as chalk} from 'chalk-template'; +/** @type {string[]} Allowed `SemVer` release types. */ export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; /** @typedef {semver.SemVer} SemVerInstance */ /** @typedef {semver.ReleaseType} SemVerIncrement */ +/** @typedef {import('chalk').ColorName} 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)); -/** @param {string[]} current @param {string[]} previous */ -const formatFirstDifference = (current, previous, {diffColor}) => { +/** +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('.'); @@ -24,13 +31,19 @@ export default class Version { #version; /** @type {SemVerIncrement | undefined} */ - diff = undefined; + #diff = undefined; - get version() { + toString() { return this.#version.version; } - set version(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) { @@ -42,10 +55,10 @@ export default class Version { /** @param {string} version - A valid `SemVer` version. - @param {SemVerIncrement} [increment] - Optionally increment `version`. If valid, `Version.diff` will be defined as the difference between `version` and the new version. + @param {SemVerIncrement} [increment] - Optionally increment `version`. */ constructor(version, increment) { - this.version = version; + this.#trySetVersion(version); if (increment) { if (!isSemVerIncrement(increment)) { @@ -57,14 +70,13 @@ export default class Version { } /** - Sets a new version based on `input`. If `input` is a valid `SemVer` increment, `Version.version` will be incrememnted by that amount and `Version.diff` will be set to `input`. If `input` is a valid `SemVer` version, `Version.version` will be set to `input` if it is greater than the current 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. @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) { - // Use getter - reference may change - const oldVersion = this.version; + const previousVersion = this.toString(); if (isSemVerIncrement(input)) { this.#version.inc(input); @@ -74,64 +86,98 @@ export default class Version { } if (this.#isGreaterThanOrEqualTo(input)) { - throw new Error(`New version \`${input}\` should be higher than current version \`${this.version}\`.`); + throw new Error(`New version \`${input}\` should be higher than current version \`${this.toString()}\`.`); } - this.version = input; + this.#trySetVersion(input); } - this.diff = semver.diff(oldVersion, this.#version); + // Set `this.#diff` to format version diffs + this.#diff = semver.diff(previousVersion, this.#version); return this; } /** + Formats the current version with `options.color`, pretty-printing the version's diff with `options.diffColor` if possible. + + 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. @param {object} options - @param {import('chalk').ColorName} [options.color = 'dim'] - @param {import('chalk').ColorName} [options.diffColor = 'cyan'] + @param {ColorName} [options.color = 'dim'] + @param {ColorName} [options.diffColor = 'cyan'] @param {string | SemVerInstance} [options.previousVersion] @returns {string} A color-formatted version string. */ - format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { - if (!this.diff) { - return chalk(`{${color} ${this.version}}`); + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { // TODO: `ColorName` type could be better to allow e.g. bgRed.blue + if (typeof previousVersion === 'string') { + const previousSemver = semver.parse(previousVersion); + + if (previousSemver === null) { + throw new Error(`Previous version \`${previousVersion}\` should be a valid \`SemVer\` version.`); + } + + previousVersion = previousSemver; + } + + // TODO: should previousVersion take precendence over this.#diff? + if (!this.#diff) { + if (!previousVersion) { + return chalk(`{${color} ${this.toString()}}`); + } + + // TODO: maybe allow passing a Version instance too? + this.#diff = semver.diff(previousVersion, this.#version); } const {major, minor, patch, prerelease} = this.#version; const previousPrerelease = semver.prerelease(previousVersion); if (prerelease && previousPrerelease) { - const prereleaseDiff = formatFirstDifference(prerelease, previousPrerelease, {diffColor}); + 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('.')}}}`) : '' // TODO: throw error if somehow invalid???? + 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('.')}}}`) : '' // TODO: throw error if somehow invalid???? ); /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ } + /** + If the current version satisifes the given `SemVer` range. + + @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.`); } - return semver.satisfies(this.version, range, { + return semver.satisfies(this.#version, range, { includePrerelease: true, }); } + /** + If the current version has any `prerelease` components. + */ isPrerelease() { return Boolean(semver.prerelease(this.#version)); } + /** + If the current version is the same as or higher than the given version. + + @param {string} otherVersion + */ #isGreaterThanOrEqualTo(otherVersion) { return semver.gte(this.#version, otherVersion); } diff --git a/test/version.js b/test/version.js index 62fedaaf..2a546a60 100644 --- a/test/version.js +++ b/test/version.js @@ -1,5 +1,7 @@ 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'; const INCREMENT_LIST = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`'; @@ -11,7 +13,7 @@ const makeNewFormattedVersion = input => { }; test('new Version - valid', t => { - t.is(new Version('1.0.0').version, '1.0.0'); + t.is(new Version('1.0.0').toString(), '1.0.0'); }); test('new Version - invalid', t => { @@ -22,7 +24,7 @@ test('new Version - invalid', t => { }); test('new Version - valid w/ valid increment', t => { - t.is(new Version('1.0.0', 'major').version, '2.0.0'); + t.is(new Version('1.0.0', 'major').toString(), '2.0.0'); }); test('new Version - invalid w/ valid increment', t => { @@ -48,7 +50,7 @@ test('new Version - invalid w/ invalid increment', t => { // 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').version, '2.0.0'); + t.is(new Version('1.0.0').setFrom('2.0.0').toString(), '2.0.0'); }); test('setFrom - invalid input as version', t => { @@ -69,6 +71,13 @@ test('format', t => { t.is(new Version('0.0.0').format(), makeNewFormattedVersion('0.0.0')); }); +test('format - set diff', t => { + t.is( + new Version('1.0.0').format({previousVersion: '0.0.0'}), + makeNewFormattedVersion('{1}.0.0'), + ); +}); + test('format - major', t => { const newVersion = makeNewFormattedVersion('{1}.0.0'); @@ -136,9 +145,16 @@ test('format - prerelease with text', t => { }); 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'}), - makeNewFormattedVersion('0.0.0-1.{2}'), + newVersion, + ); + + t.is( + new Version('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, ); }); @@ -164,6 +180,28 @@ test('format - custom colors', t => { ); }); +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')); From 21eeab16281a4a2f820b5448d529dfe5c6d87682 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 13 Apr 2023 15:28:57 -0500 Subject: [PATCH 17/63] tests(`prerequisite-tasks`): test is passing now? --- test/tasks/prerequisite-tasks.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index aa344eb8..45b80676 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -228,8 +228,7 @@ test.serial('should not fail when prerelease version of private package without ); }); -// TODO: not sure why failing -test.serial.failing('should fail when git tag already exists', createFixture, [{ +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}) => { From d234532fbdafd55858ee9435d5aeacbf3a3cf7fa Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 13 Apr 2023 19:47:53 -0500 Subject: [PATCH 18/63] tests: add more `git` and `npm` tests --- source/git-util.js | 6 +++--- test/git/stub.js | 34 +++++++++++++++++++++++++++++ test/npm/2fa-and-handle-error.js | 15 +++++++++++++ test/npm/publish.js | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 test/npm/2fa-and-handle-error.js create mode 100644 test/npm/publish.js diff --git a/source/git-util.js b/source/git-util.js index 66d910e2..6105634b 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -233,8 +233,8 @@ export const commitLogFromRevision = async revision => { return stdout; }; -const push = async () => { - await execa('git', ['push', '--follow-tags']); +const push = async (tagArg = '--follow-tags') => { + await execa('git', ['push', tagArg]); }; export const pushGraceful = async remoteIsOnGitHub => { @@ -243,7 +243,7 @@ export const pushGraceful = async remoteIsOnGitHub => { } 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.'}; } diff --git a/test/git/stub.js b/test/git/stub.js index 0ad2147c..25434094 100644 --- a/test/git/stub.js +++ b/test/git/stub.js @@ -91,6 +91,40 @@ test('git-util.verifyTagDoesNotExistOnRemote - does not exist', createFixture, [ // TODO: git-util.verifyTagDoesNotExistOnRemote - test when tagExistsOnRemote() errors +test('git-util.pushGraceful - succeeds', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 0, +}], async ({t, testedModule: git}) => { + await t.notThrowsAsync( + git.pushGraceful(), + ); +}); + +test('git-util.pushGraceful - 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: git}) => { + const {pushed, reason} = await git.pushGraceful(true); + + t.is(pushed, 'tags'); + t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); +}); + +test('git-util.pushGraceful - throws', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 1, +}], async ({t, testedModule: git}) => { + await t.throwsAsync( + git.pushGraceful(false), + ); +}); + test('git-util.verifyRecentGitVersion - satisfied', createFixture, [{ command: 'git version', stdout: 'git version 2.12.0', // One higher than minimum diff --git a/test/npm/2fa-and-handle-error.js b/test/npm/2fa-and-handle-error.js new file mode 100644 index 00000000..4cd3f628 --- /dev/null +++ b/test/npm/2fa-and-handle-error.js @@ -0,0 +1,15 @@ +import test from 'ava'; +import enable2fa, {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; +import handleNpmError from '../../source/npm/handle-npm-error.js'; + +test('getEnable2faArgs - no options', t => { + t.deepEqual(getEnable2faArgs('np'), ['access', '2fa-required', 'np']); +}); + +test('getEnable2faArgs - options, no otp', t => { + t.deepEqual(getEnable2faArgs('np', {confirm: true}), ['access', '2fa-required', 'np']); +}); + +test('getEnable2faArgs - options w/ otp', t => { + t.deepEqual(getEnable2faArgs('np', {otp: '123456'}), ['access', '2fa-required', 'np', '--otp', '123456']); +}); diff --git a/test/npm/publish.js b/test/npm/publish.js new file mode 100644 index 00000000..a37eff55 --- /dev/null +++ b/test/npm/publish.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import publish, {getPackagePublishArguments} from '../../source/npm/publish.js'; + +test('getPackagePublishArguments - no options set', t => { + t.deepEqual( + getPackagePublishArguments({}), + ['publish'], + ); +}); + +test('getPackagePublishArguments - options.contents', t => { + t.deepEqual( + getPackagePublishArguments({contents: 'dist'}), + ['publish', 'dist'], + ); +}); + +test('getPackagePublishArguments - options.tag', t => { + t.deepEqual( + getPackagePublishArguments({tag: 'beta'}), + ['publish', '--tag', 'beta'], + ); +}); + +test('getPackagePublishArguments - options.otp', t => { + t.deepEqual( + getPackagePublishArguments({otp: '123456'}), + ['publish', '--otp', '123456'], + ); +}); + +test('getPackagePublishArguments - options.publishScoped', t => { + t.deepEqual( + getPackagePublishArguments({publishScoped: true}), + ['publish', '--access', 'public'], + ); +}); From d258504936f888ef2904ad3ce485a4502c8679da Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 13 Apr 2023 19:53:23 -0500 Subject: [PATCH 19/63] fix: comment out unused imports --- test/npm/2fa-and-handle-error.js | 8 ++++++-- test/npm/publish.js | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/npm/2fa-and-handle-error.js b/test/npm/2fa-and-handle-error.js index 4cd3f628..6b8d8b66 100644 --- a/test/npm/2fa-and-handle-error.js +++ b/test/npm/2fa-and-handle-error.js @@ -1,6 +1,10 @@ import test from 'ava'; -import enable2fa, {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; -import handleNpmError from '../../source/npm/handle-npm-error.js'; +import {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; +// +// import enable2fa, {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; +// import handleNpmError from '../../source/npm/handle-npm-error.js'; + +// TODO: update for #693 test('getEnable2faArgs - no options', t => { t.deepEqual(getEnable2faArgs('np'), ['access', '2fa-required', 'np']); diff --git a/test/npm/publish.js b/test/npm/publish.js index a37eff55..f0a5711c 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -1,5 +1,7 @@ import test from 'ava'; -import publish, {getPackagePublishArguments} from '../../source/npm/publish.js'; +import {getPackagePublishArguments} from '../../source/npm/publish.js'; +// +// import publish, {getPackagePublishArguments} from '../../source/npm/publish.js'; test('getPackagePublishArguments - no options set', t => { t.deepEqual( From 7a39e51f8ed4eed2793e0512caedd293029ef5e3 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 13 Apr 2023 20:07:33 -0500 Subject: [PATCH 20/63] docs: update old info on readme --- readme.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index f04dcf7a..bdb47ded 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 @@ -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 From 12a365012b00d657611e636222c4382458d220dd Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 18 Apr 2023 12:49:28 -0500 Subject: [PATCH 21/63] add `ui`/`cli` tests, improve `customVersion` validation, style improvements --- package.json | 3 + source/cli-implementation.js | 9 +- source/cli.js | 5 +- source/ui.js | 27 +++- source/util.js | 5 +- source/version.js | 2 +- test/_helpers/mock-inquirer.js | 197 ++++++++++++++++++++++++++++ test/_helpers/stub-execa.js | 14 +- test/_helpers/verify-cli.d.ts | 10 ++ test/_helpers/verify-cli.js | 16 +++ test/cli.js | 43 ++++++ test/ui/new-files-dependencies.d.ts | 32 +++++ test/ui/new-files-dependencies.js | 120 +++++++++++++++++ test/ui/prompts/tags.js | 110 ++++++++++++++++ test/ui/prompts/version.js | 116 ++++++++++++++++ 15 files changed, 692 insertions(+), 17 deletions(-) create mode 100644 test/_helpers/mock-inquirer.js create mode 100644 test/_helpers/verify-cli.d.ts create mode 100644 test/_helpers/verify-cli.js create mode 100644 test/cli.js create mode 100644 test/ui/new-files-dependencies.d.ts create mode 100644 test/ui/new-files-dependencies.js create mode 100644 test/ui/prompts/tags.js create mode 100644 test/ui/prompts/version.js diff --git a/package.json b/package.json index af2a61ee..8a30ab8a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "p-timeout": "^6.1.1", "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", + "read-pkg": "^8.0.0", "read-pkg-up": "^9.1.0", "rxjs": "^7.8.0", "semver": "^7.4.0", @@ -70,11 +71,13 @@ "update-notifier": "^6.0.2" }, "devDependencies": { + "@sindresorhus/is": "^5.3.0", "ava": "^5.2.0", "chalk-template": "^1.0.0", "common-tags": "^1.8.2", "esmock": "^2.2.1", "fs-extra": "^11.1.1", + "map-obj": "^5.0.2", "sinon": "^15.0.3", "strip-ansi": "^7.0.1", "tempy": "^3.0.0", diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 6bdf95f2..1eb6dbcf 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -101,6 +101,7 @@ updateNotifier({pkg: cli.pkg}).notify(); try { const {pkg, rootDir} = await util.readPkg(cli.flags.contents); + // TODO: move defaults to meow flags? const defaultFlags = { cleanup: true, tests: true, @@ -121,6 +122,7 @@ try { // Workaround for unintended auto-casing behavior from `meow`. if ('2Fa' in flags) { flags['2fa'] = flags['2Fa']; + // TODO: delete flags['2Fa']? } const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; @@ -131,14 +133,15 @@ try { }; // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. - const version = flags.releaseDraftOnly ? pkg.version : (cli.input.at(0) ?? false); // TODO: can this be undefined? + 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..a44e9579 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,17 +1,16 @@ #!/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.'); } + // TODO: what is this even doing? await import('./cli-implementation.js'); } diff --git a/source/ui.js b/source/ui.js index 010f44b1..56ac7b93 100644 --- a/source/ui.js +++ b/source/ui.js @@ -170,6 +170,7 @@ const ui = async (options, {pkg, rootDir}) => { } } + // Non-interactive mode - return before prompting if (options.version) { return { ...options, @@ -215,6 +216,7 @@ 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: { @@ -238,7 +240,28 @@ const ui = async (options, {pkg, rootDir}) => { type: 'input', message: 'Version', when: answers => answers.version === undefined, - filter: input => new Version(oldVersion).setFrom(input), // Version error handling does validation + filter(input) { + if (SEMVER_INCREMENTS.includes(input)) { + throw new Error('Custom version should not be a `SemVer` increment.'); + } + + 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 version; + }, }, tag: { type: 'list', @@ -275,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 18dbc528..bf16bfd4 100644 --- a/source/util.js +++ b/source/util.js @@ -1,5 +1,6 @@ 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'; @@ -83,8 +84,8 @@ export const getNewFiles = async rootDir => { }; export const getNewDependencies = async (newPkg, rootDir) => { - let oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); - oldPkg = JSON.parse(oldPkg); + const oldPkgFile = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + const oldPkg = parsePackage(oldPkgFile); const newDependencies = []; diff --git a/source/version.js b/source/version.js index 3cc792c4..f7715b5c 100644 --- a/source/version.js +++ b/source/version.js @@ -3,7 +3,7 @@ import {template as chalk} from 'chalk-template'; /** @type {string[]} Allowed `SemVer` release types. */ export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); -const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; +export const SEMVER_INCREMENTS_LIST = `\`${SEMVER_INCREMENTS.join('`, `')}\``; /** @typedef {semver.SemVer} SemVerInstance */ /** @typedef {semver.ReleaseType} SemVerIncrement */ diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js new file mode 100644 index 00000000..18905bd4 --- /dev/null +++ b/test/_helpers/mock-inquirer.js @@ -0,0 +1,197 @@ +import {debuglog} from 'node:util'; +import esmock from 'esmock'; +import is from '@sindresorhus/is'; +import stripAnsi from 'strip-ansi'; +import mapObject from 'map-obj'; + +// NOTE: This only handles prompts of type 'input', 'list', and 'confirm'. If other prompt types are added, they must be implemented here. +// Based on https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 + +const log = debuglog('np-test'); + +/** @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 */ + +/** +@param {object} o +@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; + } + + 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) { + log(`skipping prompt '${name}'`); + continue; + } + + if (is.function_(prompt.when) && !prompt.when(answers)) { + log(`skipping prompt '${name}'`); + continue; + } + } + + 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)) { + log(`filtering value '${value}' for prompt '${name}'`); + } else { + log(`filtering value for prompt '${name}':`, value); + } + + answers[name] = prompt.filter + ? prompt.filter(value) // eslint-disable-line unicorn/no-array-callback-reference + : value; + + 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; + } + + 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)) { + log(`found input for prompt '${name}': '${input}'`); + } else { + 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; +}; + +/** @param {import('esmock').MockMap} mocks */ +const fixRelativeMocks = mocks => mapObject(mocks, (key, value) => [key.replace('./', '../../source/'), value]); + +/** +@param {object} o +@param {ExecutionContext} o.t +@param {Answers} o.answers +@param {import('esmock').MockMap} [o.mocks] +@returns {Promise} +*/ +export const mockInquirer = async ({t, answers, mocks = {}}) => ( + esmock('../../source/ui.js', import.meta.url, { + inquirer: { + prompt: async prompts => mockPrompt({t, inputAnswers: answers, prompts}), + }, + }, fixRelativeMocks(mocks)) +); diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index 6b39b563..c613b218 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -23,18 +23,20 @@ const makeExecaStub = commands => { return stub; }; -const _stubExeca = (source, importMeta) => async commands => { +const stubExeca = commands => { const execaStub = makeExecaStub(commands); - return esmock(source, importMeta, {}, { + return { execa: { - execa: async (...args) => execaStub.resolves(execa(...args))(...args), + async execa(...args) { + execaStub.resolves(execa(...args)); + return execaStub(...args); + }, }, - }); + }; }; export const _createFixture = (source, importMeta) => test.macro(async (t, commands, assertions) => { - const stubExeca = _stubExeca(source, importMeta); - const testedModule = await stubExeca(commands); + const testedModule = await esmock(source, importMeta, {}, stubExeca(commands)); await assertions({t, testedModule}); }); 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..64e1e2b9 --- /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.trim().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/cli.js b/test/cli.js new file mode 100644 index 00000000..a0e567d5 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,43 @@ +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'); + +// TODO: update help text in readme +test('flags: --help', cliPasses, cli, '--help', [ + 'A better `npm publish`', + '', + 'Usage', + '$ np ', + '', + 'Version can be:', + 'major | minor | patch | premajor | preminor | prepatch | 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/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts new file mode 100644 index 00000000..febc062a --- /dev/null +++ b/test/ui/new-files-dependencies.d.ts @@ -0,0 +1,32 @@ +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 Expected = { + unpublished: Array<`- ${string}`>; + firstTime: Array<`- ${string}`>; + dependencies: Array<`- ${string}`>; +}; + +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..7714c6d6 --- /dev/null +++ b/test/ui/new-files-dependencies.js @@ -0,0 +1,120 @@ +import test from 'ava'; +import {writePackage} from 'write-pkg'; +import {execa} from 'execa'; +import stripAnsi from 'strip-ansi'; +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, assertions = async () => {}) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + pkg = { + name: 'foo', + version: '0.0.0', + dependencies: {}, + ...pkg, + }; + + await writePackage(temporaryDir, pkg); + + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + + await commands({t, $$, temporaryDir}); + + const ui = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'is-interactive': () => false, + }}); + + // TODO: use esmock if iambumblehead/esmock#198 lands + const consoleLog = console.log; + let logs = []; + + globalThis.console.log = (...args) => logs.push(...args); + + await ui({runPublish: true, version: 'major'}, {pkg, rootDir: temporaryDir}); + + globalThis.console.log = consoleLog; + logs = logs.join('').split('\n').map(log => stripAnsi(log)); + + const {unpublished, firstTime, dependencies} = expected; + + if (unpublished) { + checkNewUnpublished(t, logs, unpublished); + } + + if (firstTime) { + checkFirstTimeFiles(t, logs, firstTime); + } + + if (dependencies) { + checkNewDependencies(t, logs, dependencies); + } + + await assertions({t, $$, temporaryDir, logs}); + }); +}); + +test.serial('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new']}); + +test.serial('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']}); + +// TODO: use sindresorhus/write-pkg#21 +test.serial.failing('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], dependencies: ['- cat-names']}); + +test.serial('first time', createFixture, {}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {firstTime: ['- new']}); + +test.serial.failing('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {firstTime: ['- new'], dependencies: ['- cat-names']}); + +test.serial.failing('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDir}) => { + await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {dependencies: ['- cat-names']}); + +test.serial.failing('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 writePackage(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..a241d30a --- /dev/null +++ b/test/ui/prompts/tags.js @@ -0,0 +1,110 @@ +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 = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub(), + 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 consoleLog = console.log; + const logs = []; + + globalThis.console.log = (...args) => logs.push(...args); + + const results = await ui({ + runPublish: true, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + globalThis.console.log = consoleLog; + + await assertions({t, results, logs}); +}); + +test.serial('choose next', testUi, '0.0.0', ['next'], { + version: 'prerelease', + tag: 'next', +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'next'); +}); + +test.serial('choose beta', testUi, '0.0.0', ['beta', 'stable'], { + version: 'prerelease', + tag: 'beta', +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'beta'); +}); + +test.serial('choose custom', testUi, '0.0.0', ['next'], { + version: 'prerelease', + tag: 'Other (specify)', + customTag: 'alpha', +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +test.serial('choose custom - validation', testUi, '0.0.0', ['next'], { + 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.serial(`works for ${version}`, testUi, '0.0.0', ['next'], { + 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..31475c1c --- /dev/null +++ b/test/ui/prompts/version.js @@ -0,0 +1,116 @@ +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 = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub(), + }, + './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 consoleLog = console.log; + const logs = []; + + globalThis.console.log = (...args) => logs.push(...args); + + const results = await ui({ + runPublish: false, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + globalThis.console.log = consoleLog; + + await assertions({t, results, logs}); +}); + +test.serial('choose major', testUi, '0.0.0', { + version: 'major', +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test.serial('choose minor', testUi, '0.0.0', { + version: 'minor', +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0'); +}); + +test.serial('choose patch', testUi, '0.0.0', { + version: 'patch', +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1'); +}); + +test.serial('choose premajor', testUi, '0.0.0', { + version: 'premajor', +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-0'); +}); + +test.serial('choose preminor', testUi, '0.0.0', { + version: 'preminor', +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0-0'); +}); + +test.serial('choose prepatch', testUi, '0.0.0', { + version: 'prepatch', +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-0'); +}); + +test.serial('choose prerelease', testUi, '0.0.1-0', { + version: 'prerelease', +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-1'); +}); + +test.serial('choose custom', testUi, '0.0.0', { + version: 'Other (specify)', + customVersion: '1.0.0', +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test.serial('choose custom - validation', testUi, '1.0.0', { + 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'); +}); From ef1d2e95e019d7d8723e5a3766141a46d76ab868 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 18 Apr 2023 18:50:31 -0500 Subject: [PATCH 22/63] update CI to Node.js 20 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d58c737..ad2bece0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: node-version: - - 19 + - 20 - 18 - 16 steps: From b854b817df4ce50da64a6ec5f2fa15dd27a34174 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 18 Apr 2023 19:10:18 -0500 Subject: [PATCH 23/63] tests(`cli`): try to fix failure on CI --- test/_helpers/verify-cli.js | 2 +- test/cli.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/_helpers/verify-cli.js b/test/_helpers/verify-cli.js index 64e1e2b9..9d3985a8 100644 --- a/test/_helpers/verify-cli.js +++ b/test/_helpers/verify-cli.js @@ -2,7 +2,7 @@ import test from 'ava'; import {execa} from 'execa'; -const trim = stdout => stdout.trim().split('\n').map(line => line.trim()); +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}); diff --git a/test/cli.js b/test/cli.js index a0e567d5..a37bc1eb 100644 --- a/test/cli.js +++ b/test/cli.js @@ -3,10 +3,11 @@ 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'); +const cli = path.resolve(rootDir, 'source', 'cli-implementation.js'); // TODO: update help text in readme test('flags: --help', cliPasses, cli, '--help', [ + '', 'A better `npm publish`', '', 'Usage', @@ -38,6 +39,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '$ 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]); From 9d810a16dcf503ed8f5ca3b868352e69f1e32572 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 18 Apr 2023 19:26:33 -0500 Subject: [PATCH 24/63] feat: add changes from #932, add tests --- source/npm/enable-2fa.js | 6 ++-- source/npm/util.js | 2 +- test/npm/2fa-and-handle-error.js | 51 +++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 14 deletions(-) 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/util.js b/source/npm/util.js index 0b16b7a1..f15665ac 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -8,7 +8,7 @@ import chalk from 'chalk'; import Version from '../version.js'; import * as util from '../util.js'; -const version = async () => { +export const version = async () => { const {stdout} = await execa('npm', ['--version']); return stdout; }; diff --git a/test/npm/2fa-and-handle-error.js b/test/npm/2fa-and-handle-error.js index 6b8d8b66..44c53a18 100644 --- a/test/npm/2fa-and-handle-error.js +++ b/test/npm/2fa-and-handle-error.js @@ -1,19 +1,48 @@ import test from 'ava'; -import {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; // // import enable2fa, {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; // import handleNpmError from '../../source/npm/handle-npm-error.js'; -// TODO: update for #693 +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/npm/enable-2fa.js', import.meta.url); -test('getEnable2faArgs - no options', t => { - t.deepEqual(getEnable2faArgs('np'), ['access', '2fa-required', 'np']); -}); +const npmVersionFixtures = [ + {version: '8.0.0', accessArgs: ['access', '2fa-required']}, + {version: '9.0.0', accessArgs: ['access', 'set', 'mfa=publish']}, +]; -test('getEnable2faArgs - options, no otp', t => { - t.deepEqual(getEnable2faArgs('np', {confirm: true}), ['access', '2fa-required', 'np']); -}); +for (const {version, accessArgs} of npmVersionFixtures) { + const npmVersionCommand = [{ + command: 'npm --version', + stdout: version, + }]; + + test(`npm v${version} - getEnable2faArgs - no options`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np'), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - getEnable2faArgs - options, no otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {confirm: true}), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - getEnable2faArgs - options, with otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {otp: '123456'}), + [...accessArgs, 'np', '--otp', '123456'], + ); + }, + ); +} -test('getEnable2faArgs - options w/ otp', t => { - t.deepEqual(getEnable2faArgs('np', {otp: '123456'}), ['access', '2fa-required', 'np', '--otp', '123456']); -}); From f2bc5250098a38bc69f88b7202589b4dd95007c5 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 18 Apr 2023 19:33:09 -0500 Subject: [PATCH 25/63] undo Node.js 20 on CI, issues with `esmock` --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ad2bece0..2d58c737 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: node-version: - - 20 + - 19 - 18 - 16 steps: From e403bf9b591508e8fda94759ed464e6e002d1f06 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sat, 1 Jul 2023 22:12:44 -0500 Subject: [PATCH 26/63] update deps --- package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 3e2d855f..f456ee6c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "commit" ], "dependencies": { - "chalk": "^5.2.0", + "chalk": "^5.3.0", "cosmiconfig": "^8.1.3", "del": "^7.0.0", "escape-goat": "^4.0.0", @@ -43,7 +43,7 @@ "hosted-git-info": "^6.1.1", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.6", + "inquirer": "^9.2.7", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -58,27 +58,27 @@ "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": "^8.0.0", "read-pkg-up": "^9.1.0", "rxjs": "^7.8.1", - "semver": "^7.5.1", + "semver": "^7.5.3", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" }, "devDependencies": { - "@sindresorhus/is": "^5.3.0", - "ava": "^5.3.0", - "chalk-template": "^1.0.0", + "@sindresorhus/is": "^5.4.1", + "ava": "^5.3.1", + "chalk-template": "^1.1.0", "common-tags": "^1.8.2", "esmock": "^2.2.3", "fs-extra": "^11.1.1", "map-obj": "^5.0.2", - "sinon": "^15.1.0", - "strip-ansi": "^7.0.1", + "sinon": "^15.2.0", + "strip-ansi": "^7.1.0", "tempy": "^3.0.0", "write-pkg": "^5.1.0", "xo": "^0.54.2" From 26accf9344e778832fbc9d55e95e229d752d3b73 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 20:27:01 -0500 Subject: [PATCH 27/63] fix(`npm`): incorrect version handling, missing async, style fix --- source/npm/util.js | 8 ++++---- source/ui.js | 2 +- test/ui/prompts/tags.js | 2 +- test/ui/prompts/version.js | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index f15665ac..e0220524 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -45,7 +45,7 @@ export const username = async ({externalRegistry}) => { } }; -export const isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; +export const isExternalRegistry = pkg => typeof pkg.publishConfig?.registry === 'string'; export const collaborators = async pkg => { const packageName = pkg.name; @@ -53,7 +53,7 @@ export const collaborators = async pkg => { const npmVersion = await version(); // TODO: remove old command when targeting Node.js 18 - const args = new Version(npmVersion).satisfies('>=9.0.0') + const args = new Version(npmVersion).satisfies('<9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; @@ -133,8 +133,8 @@ export const verifyRecentNpmVersion = async () => { 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(` diff --git a/source/ui.js b/source/ui.js index ae2e7f21..637c4759 100644 --- a/source/ui.js +++ b/source/ui.js @@ -129,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) { diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index a241d30a..181cfe32 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -7,7 +7,7 @@ const testUi = test.macro(async (t, version, tags, answers, assertions) => { const ui = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { './npm/util.js': { getRegistryUrl: sinon.stub().resolves(''), - checkIgnoreStrategy: sinon.stub(), + checkIgnoreStrategy: sinon.stub().resolves(), prereleaseTags: sinon.stub().resolves(tags), }, './util.js': { diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 31475c1c..2fc0d8c5 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -6,7 +6,7 @@ const testUi = test.macro(async (t, version, answers, assertions) => { const ui = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { './npm/util.js': { getRegistryUrl: sinon.stub().resolves(''), - checkIgnoreStrategy: sinon.stub(), + checkIgnoreStrategy: sinon.stub().resolves(), }, './util.js': { getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), From 366c8c34b4f1a5a1e1eebbf2ae7c8509ea6babee Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 20:27:13 -0500 Subject: [PATCH 28/63] suppress ESM warnings on tests --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f456ee6c..7faddbd1 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "FORCE_HYPERLINK": "1" }, "nodeArguments": [ - "--loader=esmock" + "--loader=esmock", + "--no-warnings=ExperimentalWarning" ] } } From f5331c4a16d0436e2f0edb8ba85158df92346791 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 20:28:02 -0500 Subject: [PATCH 29/63] tests(`npm`): finish adding tests, seperate into own files --- ...{2fa-and-handle-error.js => enable-2fa.js} | 9 +- test/npm/handle-npm-error.js | 22 +++++ test/npm/publish.js | 12 +-- test/npm/util/check-connection.js | 36 +++++++ test/npm/util/check-ignore-strategy.js | 35 +++++++ test/npm/util/collaborators.js | 93 +++++++++++++++++++ test/npm/util/get-registry-url.js | 39 ++++++++ .../util/{unit.js => is-external-registry.js} | 4 +- test/npm/util/is-package-name-available.js | 42 +++++++++ test/npm/{ => util}/packed-files.js | 4 +- test/npm/util/prerelease-tags.js | 89 ++++++++++++++++++ test/npm/util/stub.js | 89 ------------------ test/npm/util/username.js | 39 ++++++++ test/npm/util/verify-recent-npm-version.js | 24 +++++ 14 files changed, 431 insertions(+), 106 deletions(-) rename test/npm/{2fa-and-handle-error.js => enable-2fa.js} (70%) create mode 100644 test/npm/handle-npm-error.js create mode 100644 test/npm/util/check-connection.js create mode 100644 test/npm/util/check-ignore-strategy.js create mode 100644 test/npm/util/collaborators.js create mode 100644 test/npm/util/get-registry-url.js rename test/npm/util/{unit.js => is-external-registry.js} (69%) create mode 100644 test/npm/util/is-package-name-available.js rename test/npm/{ => util}/packed-files.js (94%) create mode 100644 test/npm/util/prerelease-tags.js delete mode 100644 test/npm/util/stub.js create mode 100644 test/npm/util/username.js create mode 100644 test/npm/util/verify-recent-npm-version.js diff --git a/test/npm/2fa-and-handle-error.js b/test/npm/enable-2fa.js similarity index 70% rename from test/npm/2fa-and-handle-error.js rename to test/npm/enable-2fa.js index 44c53a18..59fa5203 100644 --- a/test/npm/2fa-and-handle-error.js +++ b/test/npm/enable-2fa.js @@ -1,8 +1,5 @@ import test from 'ava'; import {_createFixture} from '../_helpers/stub-execa.js'; -// -// import enable2fa, {getEnable2faArgs} from '../../source/npm/enable-2fa.js'; -// import handleNpmError from '../../source/npm/handle-npm-error.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/npm/enable-2fa.js', import.meta.url); @@ -18,7 +15,7 @@ for (const {version, accessArgs} of npmVersionFixtures) { stdout: version, }]; - test(`npm v${version} - getEnable2faArgs - no options`, createFixture, npmVersionCommand, + test(`npm v${version} - no options`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArgs}}) => { t.deepEqual( await getEnable2faArgs('np'), @@ -27,7 +24,7 @@ for (const {version, accessArgs} of npmVersionFixtures) { }, ); - test(`npm v${version} - getEnable2faArgs - options, no otp`, createFixture, npmVersionCommand, + test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArgs}}) => { t.deepEqual( await getEnable2faArgs('np', {confirm: true}), @@ -36,7 +33,7 @@ for (const {version, accessArgs} of npmVersionFixtures) { }, ); - test(`npm v${version} - getEnable2faArgs - options, with otp`, createFixture, npmVersionCommand, + test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArgs}}) => { t.deepEqual( await getEnable2faArgs('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..42dd70c7 --- /dev/null +++ b/test/npm/handle-npm-error.js @@ -0,0 +1,22 @@ +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?'}, + ); +}); + +// TODO: OTP test? diff --git a/test/npm/publish.js b/test/npm/publish.js index f0a5711c..316747fa 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -1,37 +1,35 @@ import test from 'ava'; import {getPackagePublishArguments} from '../../source/npm/publish.js'; -// -// import publish, {getPackagePublishArguments} from '../../source/npm/publish.js'; -test('getPackagePublishArguments - no options set', t => { +test('no options set', t => { t.deepEqual( getPackagePublishArguments({}), ['publish'], ); }); -test('getPackagePublishArguments - options.contents', t => { +test('options.contents', t => { t.deepEqual( getPackagePublishArguments({contents: 'dist'}), ['publish', 'dist'], ); }); -test('getPackagePublishArguments - options.tag', t => { +test('options.tag', t => { t.deepEqual( getPackagePublishArguments({tag: 'beta'}), ['publish', '--tag', 'beta'], ); }); -test('getPackagePublishArguments - options.otp', t => { +test('options.otp', t => { t.deepEqual( getPackagePublishArguments({otp: '123456'}), ['publish', '--otp', '123456'], ); }); -test('getPackagePublishArguments - options.publishScoped', t => { +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..e1c6d695 --- /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 list collaborators np --json'}, + {version: '9.0.0', accessCommand: 'npm access ls-collaborators np'}, +]; + +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 list collaborators non-existent --json' + : 'npm access ls-collaborators non-existent', + 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 list collaborators @private/pkg --json' + : 'npm access ls-collaborators @private/pkg', + 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/unit.js b/test/npm/util/is-external-registry.js similarity index 69% rename from test/npm/util/unit.js rename to test/npm/util/is-external-registry.js index 41b105d4..c449cc66 100644 --- a/test/npm/util/unit.js +++ b/test/npm/util/is-external-registry.js @@ -1,8 +1,8 @@ import test from 'ava'; import * as npm from '../../../source/npm/util.js'; -test('npm.isExternalRegistry', t => { - t.true(npm.isExternalRegistry({publishConfig: {registry: 'http://my.io'}})); +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}})); 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/npm/packed-files.js b/test/npm/util/packed-files.js similarity index 94% rename from test/npm/packed-files.js rename to test/npm/util/packed-files.js index debbe0aa..a3034fb9 100644 --- a/test/npm/packed-files.js +++ b/test/npm/util/packed-files.js @@ -1,7 +1,7 @@ import path from 'node:path'; import test from 'ava'; -import {getFilesToBePacked} from '../../source/npm/util.js'; -import {runIfExists} from '../_helpers/util.js'; +import {getFilesToBePacked} from '../../../source/npm/util.js'; +import {runIfExists} from '../../_helpers/util.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); 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/stub.js b/test/npm/util/stub.js deleted file mode 100644 index bab22ae8..00000000 --- a/test/npm/util/stub.js +++ /dev/null @@ -1,89 +0,0 @@ -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('npm.checkConnection - success', createFixture, [{ - command: 'npm ping', - exitCode: 0, -}], async ({t, testedModule: npm}) => { - t.true(await npm.checkConnection()); -}); - -test('npm.checkConnection - fail', createFixture, [{ - command: 'npm ping', - exitCode: 1, -}], async ({t, testedModule: npm}) => { - await t.throwsAsync( - npm.checkConnection(), - {message: 'Connection to npm registry failed'}, - ); -}); - -// TODO: find way to timeout without timing out ava -test('npm.checkConnection - timeout', async t => { - 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'}, - ); -}); - -test('npm.username', createFixture, [{ - command: 'npm whoami', - stdout: 'sindresorhus', -}], async ({t, testedModule: npm}) => { - t.is(await npm.username({}), 'sindresorhus'); -}); - -test('npm.username - --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('npm.username - 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('npm.username - 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.'}, - ); -}); - -test('npm.verifyRecentNpmVersion - satisfied', createFixture, [{ - command: 'npm --version', - stdout: '7.20.0', // One higher than minimum -}], async ({t, testedModule: npm}) => { - await t.notThrowsAsync( - npm.verifyRecentNpmVersion(), - ); -}); - -test('npm.verifyRecentNpmVersion - 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/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'}, + ); +}); From bab7bb2c60e94ebd6b78531d117ca4a0b47d919f Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 20:30:12 -0500 Subject: [PATCH 30/63] mark tests as failing --- test/ui/new-files-dependencies.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 7714c6d6..6fe46d74 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -94,7 +94,7 @@ test.serial.failing('unpublished and dependencies', createFixture, {files: ['*.j await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); }, {unpublished: ['- new'], dependencies: ['- cat-names']}); -test.serial('first time', createFixture, {}, async ({t, $$}) => { +test.serial.failing('first time', createFixture, {}, async ({t, $$}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; From 438db1e4475a2a428cd79561bb6f89e7cc888c64 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 21:11:26 -0500 Subject: [PATCH 31/63] fix(`npm.collaborators`): revert version change --- source/npm/util.js | 2 +- test/npm/util/collaborators.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index e0220524..e1187144 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -53,7 +53,7 @@ export const collaborators = async pkg => { const npmVersion = await version(); // TODO: remove old command when targeting Node.js 18 - const args = new Version(npmVersion).satisfies('<9.0.0') + const args = new Version(npmVersion).satisfies('>=9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index e1c6d695..93fb5c06 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -14,8 +14,8 @@ test('pkg.name not a string', async t => { }); const npmVersionFixtures = [ - {version: '8.0.0', accessCommand: 'npm access list collaborators np --json'}, - {version: '9.0.0', accessCommand: 'npm access ls-collaborators np'}, + {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) { @@ -67,8 +67,8 @@ for (const {version, accessCommand} of npmVersionFixtures) { npmVersionCommand, { command: version === '8.0.0' - ? 'npm access list collaborators non-existent --json' - : 'npm access ls-collaborators non-existent', + ? '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}}) => { @@ -82,8 +82,8 @@ for (const {version, accessCommand} of npmVersionFixtures) { npmVersionCommand, { command: version === '8.0.0' - ? 'npm access list collaborators @private/pkg --json' - : 'npm access ls-collaborators @private/pkg', + ? '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}}) => { From 1ed67ac70b15f21d4e0ebc6a5e01b6cdceae06e6 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 22:26:21 -0500 Subject: [PATCH 32/63] remove reminders (added to #684) --- source/index.js | 1 - source/prerequisite-tasks.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/source/index.js b/source/index.js index 94388afe..34dcc16f 100644 --- a/source/index.js +++ b/source/index.js @@ -89,7 +89,6 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); - // TODO: move tasks to subdirectory const tasks = new Listr([ { title: 'Prerequisite check', diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index a1bed295..b207c3c3 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -11,7 +11,7 @@ const prerequisiteTasks = (input, pkg, options) => { let newVersion; const tasks = [ - { // TODO: consolidate tasks in move to listr2 + { title: 'Ping npm registry', enabled: () => !pkg.private && !isExternalRegistry, task: async () => npm.checkConnection(), From da36e871438d5aa618d8b5c2a11e65e0bbf92509 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 22:31:40 -0500 Subject: [PATCH 33/63] refactor(`cli`): move defaults into `meow` configuration --- source/cli-implementation.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 72b06221..05d0aec2 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -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,24 +107,14 @@ updateNotifier({pkg: cli.pkg}).notify(); try { const {pkg, rootDir} = await util.readPkg(cli.flags.contents); - // TODO: move defaults to meow flags? - 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, }; + console.log(flags); + // Workaround for unintended auto-casing behavior from `meow`. if ('2Fa' in flags) { flags['2fa'] = flags['2Fa']; From 7534032d46986a45d36f9d1760a729dd6e953a0d Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 3 Jul 2023 22:35:46 -0500 Subject: [PATCH 34/63] remove todos, update `chalk` usage, grammar tweak --- source/cli.js | 1 - source/npm/util.js | 6 +++--- source/version.js | 5 +++-- test/version.js | 5 +++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/source/cli.js b/source/cli.js index a44e9579..259300ce 100755 --- a/source/cli.js +++ b/source/cli.js @@ -11,6 +11,5 @@ if (!importLocal(import.meta.url)) { log('Using global install of np.'); } - // TODO: what is this even doing? await import('./cli-implementation.js'); } diff --git a/source/npm/util.js b/source/npm/util.js index e1187144..10d74dd4 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -4,7 +4,7 @@ import {execa} from 'execa'; import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; -import chalk from 'chalk'; +import chalk from 'chalk-template'; import Version from '../version.js'; import * as util from '../util.js'; @@ -137,8 +137,8 @@ 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/version.js b/source/version.js index f7715b5c..5aa0b49f 100644 --- a/source/version.js +++ b/source/version.js @@ -4,10 +4,11 @@ import {template as chalk} from 'chalk-template'; /** @type {string[]} Allowed `SemVer` release types. */ export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); 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} ColorName */ +/** @typedef {import('chalk').ColorName | import('chalk').ModifierName} ColorName */ /** @param {string} input @returns {input is SemVerIncrement} */ const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); @@ -62,7 +63,7 @@ export default class Version { if (increment) { if (!isSemVerIncrement(increment)) { - throw new Error(`Increment \`${increment}\` should be one of ${SEMVER_INCREMENTS_LIST}.`); + throw new Error(`Increment \`${increment}\` should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); } this.setFrom(increment); diff --git a/test/version.js b/test/version.js index 2a546a60..d6dc835f 100644 --- a/test/version.js +++ b/test/version.js @@ -5,6 +5,7 @@ import semver from 'semver'; import Version from '../source/version.js'; const INCREMENT_LIST = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`'; +const INCREMENT_LIST_OR = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, or `prerelease`'; /** @param {string} input - Place `{ }` around the version parts to be highlighted. */ const makeNewFormattedVersion = input => { @@ -36,8 +37,8 @@ test('new Version - invalid w/ valid increment', t => { test('new Version - valid w/ invalid increment', t => { t.throws( - () => new Version('1.0.0', '2.0.0'), // TODO: join last as 'or'? - {message: `Increment \`2.0.0\` should be one of ${INCREMENT_LIST}.`}, + () => new Version('1.0.0', '2.0.0'), + {message: `Increment \`2.0.0\` should be one of ${INCREMENT_LIST_OR}.`}, ); }); From 649f7ef3efa1eb575a28b1c782f6665c45651b5f Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 18:18:39 -0500 Subject: [PATCH 35/63] update `esmock` to fix `ui` tests --- package.json | 9 ++- test/_helpers/mock-inquirer.js | 20 +++-- test/ui/new-files-dependencies.d.ts | 8 +- test/ui/new-files-dependencies.js | 80 +++++++++++--------- test/ui/prompts/tags.js | 97 +++++++++++++----------- test/ui/prompts/version.js | 110 +++++++++++++++------------- 6 files changed, 185 insertions(+), 139 deletions(-) diff --git a/package.json b/package.json index 7faddbd1..43ec2321 100644 --- a/package.json +++ b/package.json @@ -70,17 +70,18 @@ "update-notifier": "^6.0.2" }, "devDependencies": { - "@sindresorhus/is": "^5.4.1", + "@sindresorhus/is": "^5.6.0", + "@types/semver": "^7.5.0", "ava": "^5.3.1", "chalk-template": "^1.1.0", "common-tags": "^1.8.2", - "esmock": "^2.2.3", + "esmock": "^2.3.4", "fs-extra": "^11.1.1", "map-obj": "^5.0.2", "sinon": "^15.2.0", "strip-ansi": "^7.1.0", - "tempy": "^3.0.0", - "write-pkg": "^5.1.0", + "tempy": "^3.1.0", + "write-pkg": "^6.0.0", "xo": "^0.54.2" }, "ava": { diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 18905bd4..52300fc6 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -7,6 +7,7 @@ import mapObject from 'map-obj'; // NOTE: This only handles prompts of type 'input', 'list', and 'confirm'. If other prompt types are added, they must be implemented here. // Based on https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 +// TODO: log with AVA instead (NODE_DEBUG-'np-test') const log = debuglog('np-test'); /** @typedef {import('ava').ExecutionContext>} ExecutionContext */ @@ -186,12 +187,21 @@ const fixRelativeMocks = mocks => mapObject(mocks, (key, value) => [key.replace( @param {ExecutionContext} o.t @param {Answers} o.answers @param {import('esmock').MockMap} [o.mocks] -@returns {Promise} +@param {string[]} [o.logs] */ -export const mockInquirer = async ({t, answers, mocks = {}}) => ( - esmock('../../source/ui.js', import.meta.url, { +export const mockInquirer = async ({t, answers, mocks = {}, logs = []}) => { + /** @type {import('../../source/ui.js')} */ + const ui = await esmock('../../source/ui.js', import.meta.url, { inquirer: { prompt: async prompts => mockPrompt({t, inputAnswers: answers, prompts}), }, - }, fixRelativeMocks(mocks)) -); + }, { + ...fixRelativeMocks(mocks), + // Mock globals + import: { + console: {log: (...args) => logs.push(...args)}, + }, + }); + + return {ui, logs}; +}; diff --git a/test/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts index febc062a..10f1387e 100644 --- a/test/ui/new-files-dependencies.d.ts +++ b/test/ui/new-files-dependencies.d.ts @@ -11,10 +11,12 @@ type CommandsFnParameters = [{ temporaryDir: string; }]; +type ListItem = `- ${string}`; + type Expected = { - unpublished: Array<`- ${string}`>; - firstTime: Array<`- ${string}`>; - dependencies: Array<`- ${string}`>; + unpublished: ListItem[]; + firstTime: ListItem[]; + dependencies: ListItem[]; }; type AssertionsFnParameters = [{ diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 6fe46d74..5d3c1c80 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -1,7 +1,9 @@ import test from 'ava'; -import {writePackage} from 'write-pkg'; +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'; @@ -21,100 +23,106 @@ const checkFirstTimeFiles = checkLines('The following new files will be publishe 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, assertions = async () => {}) => { +const createFixture = test.macro(async (t, pkg, commands, expected) => { await createIntegrationTest(t, async ({$$, temporaryDir}) => { pkg = { - name: 'foo', + name: '@np/foo', version: '0.0.0', dependencies: {}, ...pkg, }; - await writePackage(temporaryDir, pkg); + await updatePackage(temporaryDir, pkg); await $$`git add .`; await $$`git commit -m "added"`; await $$`git tag v0.0.0`; await commands({t, $$, temporaryDir}); - - const ui = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { + pkg = await readPackage({cwd: temporaryDir}); + + // TODO: describe mocks + 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, }}); - // TODO: use esmock if iambumblehead/esmock#198 lands - const consoleLog = console.log; - let logs = []; - - globalThis.console.log = (...args) => logs.push(...args); - await ui({runPublish: true, version: 'major'}, {pkg, rootDir: temporaryDir}); - - globalThis.console.log = consoleLog; - logs = logs.join('').split('\n').map(log => stripAnsi(log)); + const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); const {unpublished, firstTime, dependencies} = expected; - if (unpublished) { - checkNewUnpublished(t, logs, unpublished); - } + const assertions = await t.try(tt => { + if (unpublished) { + checkNewUnpublished(tt, logs, unpublished); + } - if (firstTime) { - checkFirstTimeFiles(t, logs, firstTime); - } + if (firstTime) { + checkFirstTimeFiles(tt, logs, firstTime); + } + + if (dependencies) { + checkNewDependencies(tt, logs, dependencies); + } + }); - if (dependencies) { - checkNewDependencies(t, logs, dependencies); + if (!assertions.passed) { + t.log('logs:', logs); + t.log('pkg:', pkg); + t.log('expected:', expected); } - await assertions({t, $$, temporaryDir, logs}); + assertions.commit(); }); }); -test.serial('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { +test('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; }, {unpublished: ['- new']}); -test.serial('unpublished and first time', createFixture, {files: ['*.js']}, async ({t, $$}) => { +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']}); -// TODO: use sindresorhus/write-pkg#21 -test.serial.failing('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { +test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; - await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); }, {unpublished: ['- new'], dependencies: ['- cat-names']}); -test.serial.failing('first time', createFixture, {}, async ({t, $$}) => { +test('first time', createFixture, {}, async ({t, $$}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; }, {firstTime: ['- new']}); -test.serial.failing('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { +test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; - await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); }, {firstTime: ['- new'], dependencies: ['- cat-names']}); -test.serial.failing('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDir}) => { - await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +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.serial.failing('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { +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 writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + 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 index 181cfe32..52423b40 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -3,8 +3,8 @@ 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 = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { +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(), @@ -20,11 +20,6 @@ const testUi = test.macro(async (t, version, tags, answers, assertions) => { }, }}); - const consoleLog = console.log; - const logs = []; - - globalThis.console.log = (...args) => logs.push(...args); - const results = await ui({ runPublish: true, availability: {}, @@ -36,56 +31,70 @@ const testUi = test.macro(async (t, version, tags, answers, assertions) => { }, }); - globalThis.console.log = consoleLog; - await assertions({t, results, logs}); }); -test.serial('choose next', testUi, '0.0.0', ['next'], { - version: 'prerelease', - tag: 'next', +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.serial('choose beta', testUi, '0.0.0', ['beta', 'stable'], { - version: 'prerelease', - tag: 'beta', +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.serial('choose custom', testUi, '0.0.0', ['next'], { - version: 'prerelease', - tag: 'Other (specify)', - customTag: 'alpha', +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.serial('choose custom - validation', testUi, '0.0.0', ['next'], { - 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', - }, - ], +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'); @@ -100,9 +109,13 @@ const fixtures = [ ]; for (const {version, expected} of fixtures) { - test.serial(`works for ${version}`, testUi, '0.0.0', ['next'], { - version, - tag: 'next', + 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 index 2fc0d8c5..5e7267ca 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -2,8 +2,8 @@ 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 = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { +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(), @@ -18,11 +18,6 @@ const testUi = test.macro(async (t, version, answers, assertions) => { }, }}); - const consoleLog = console.log; - const logs = []; - - globalThis.console.log = (...args) => logs.push(...args); - const results = await ui({ runPublish: false, availability: {}, @@ -34,83 +29,100 @@ const testUi = test.macro(async (t, version, answers, assertions) => { }, }); - globalThis.console.log = consoleLog; - await assertions({t, results, logs}); }); -test.serial('choose major', testUi, '0.0.0', { - version: 'major', +test('choose major', testUi, { + version: '0.0.0', + answers: { + version: 'major', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '1.0.0'); }); -test.serial('choose minor', testUi, '0.0.0', { - version: 'minor', +test('choose minor', testUi, { + version: '0.0.0', answers: { + version: 'minor', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.1.0'); }); -test.serial('choose patch', testUi, '0.0.0', { - version: 'patch', +test('choose patch', testUi, { + version: '0.0.0', answers: { + version: 'patch', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.0.1'); }); -test.serial('choose premajor', testUi, '0.0.0', { - version: 'premajor', +test('choose premajor', testUi, { + version: '0.0.0', answers: { + version: 'premajor', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '1.0.0-0'); }); -test.serial('choose preminor', testUi, '0.0.0', { - version: 'preminor', +test('choose preminor', testUi, { + version: '0.0.0', answers: { + version: 'preminor', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.1.0-0'); }); -test.serial('choose prepatch', testUi, '0.0.0', { - version: 'prepatch', +test('choose prepatch', testUi, { + version: '0.0.0', answers: { + version: 'prepatch', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.0.1-0'); }); -test.serial('choose prerelease', testUi, '0.0.1-0', { - version: 'prerelease', +test('choose prerelease', testUi, { + version: '0.0.1-0', answers: { + version: 'prerelease', + }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.0.1-1'); }); -test.serial('choose custom', testUi, '0.0.0', { - version: 'Other (specify)', - customVersion: '1.0.0', +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.serial('choose custom - validation', testUi, '1.0.0', { - 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', - }, - ], +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'); }); From f3c09b2eff7ef85e2a74fb164f4c1cc7119537c8 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 18:24:31 -0500 Subject: [PATCH 36/63] fix lint --- test/fixtures/files/npmignore/index.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()); From 0d262ea6f54af50829fb46f43d938f0ba26994e8 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 18:28:12 -0500 Subject: [PATCH 37/63] fix lint --- test/fixtures/files/files-and-npmignore/source/index.test-d.ts | 2 +- test/fixtures/files/gitignore/index.test-d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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()); From 0413d1f0d0b8fea8e28cd4732f6cca7927a50079 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 19:07:58 -0500 Subject: [PATCH 38/63] move `util` tests to separate files, fix failing tests, add test for #707 --- source/util.js | 2 + test/_helpers/integration-test.js | 2 +- test/util/get-new-dependencies.js | 44 +++++++ test/util/get-new-files.js | 109 ++++++++++++++++++ test/util/integration.js | 86 -------------- test/util/join-list.js | 23 ++++ test/util/read-pkg.js | 38 ++++++ test/util/unit.js | 72 ------------ .../util/validate-engine-version-satisfies.js | 24 ++++ 9 files changed, 241 insertions(+), 159 deletions(-) create mode 100644 test/util/get-new-dependencies.js create mode 100644 test/util/get-new-files.js delete mode 100644 test/util/integration.js create mode 100644 test/util/join-list.js create mode 100644 test/util/read-pkg.js delete mode 100644 test/util/unit.js create mode 100644 test/util/validate-engine-version-satisfies.js diff --git a/source/util.js b/source/util.js index 94d8de62..88b2ded5 100644 --- a/source/util.js +++ b/source/util.js @@ -59,6 +59,7 @@ export const getTagVersionPrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { + // TODO: test with 'options.yarn' if (options.yarn) { const {stdout} = await execa('yarn', ['config', 'get', 'version-tag-prefix']); return stdout; @@ -106,6 +107,7 @@ export const getNewDependencies = async (newPkg, rootDir) => { return newDependencies; }; +// TODO: test export const getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index 427120de..ed3f5cb7 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -23,7 +23,7 @@ export const createIntegrationTest = async (t, assertions) => { await createEmptyGitRepo($$, temporaryDir); - t.context.createFile = async (file, content = '') => fs.writeFile(path.resolve(temporaryDir, file), content); + t.context.createFile = async (file, content = '') => fs.outputFile(path.resolve(temporaryDir, file), content); await assertions({$$, temporaryDir}); }); }; diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js new file mode 100644 index 00000000..143c4d03 --- /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 -A`; + 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/integration.js b/test/util/integration.js deleted file mode 100644 index 50cea8c8..00000000 --- a/test/util/integration.js +++ /dev/null @@ -1,86 +0,0 @@ -import path from 'node:path'; -import test from 'ava'; -import esmock from 'esmock'; -import {execa} from 'execa'; -import {writePackage} from 'write-pkg'; -import {readPackage} from 'read-pkg'; -import {createIntegrationTest, _createFixture} from '../_helpers/integration-test.js'; - -/** @type {ReturnType>} */ -const createFixture = _createFixture('../../source/util.js'); - -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('util.getNewFiles - 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('util.getNewFiles - 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'); - - // TODO: not sure why failing - test.failing('util.getNewFiles - 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('util.getNewFiles - no new files added', createNewFilesFixture, [], async ({$$}) => { - await $$`git tag v0.0.0`; -}, {unpublished: [], firstTime: []}); - -// TODO: not sure why failing -test.failing('util.getNewFiles - 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.md'); - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, {unpublished: [], firstTime: ['index.js']}); - -test('util.getNewDependencies', createFixture, async ({$$, temporaryDir}) => { - await writePackage(temporaryDir, {dependencies: {'dog-names': '^2.1.0'}}); - await $$`git add -A`; - await $$`git commit -m "added"`; - await $$`git tag v0.0.0`; - await writePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); -}, async ({t, testedModule: util, temporaryDir}) => { - const pkg = await readPackage({cwd: temporaryDir}); - t.deepEqual(await util.getNewDependencies(pkg, temporaryDir), ['cat-names']); -}); 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..9fd7132c --- /dev/null +++ b/test/util/read-pkg.js @@ -0,0 +1,38 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import test from 'ava'; +import {temporaryDirectory} from 'tempy'; +import {readPkg, npPkg, npRootDir} from '../../source/util.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '../..'); + +test('without packagePath', 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); +}); diff --git a/test/util/unit.js b/test/util/unit.js deleted file mode 100644 index 6e036b40..00000000 --- a/test/util/unit.js +++ /dev/null @@ -1,72 +0,0 @@ -import {fileURLToPath} from 'node:url'; -import path from 'node:path'; -import test from 'ava'; -import {temporaryDirectory} from 'tempy'; -import stripAnsi from 'strip-ansi'; -import * as util from '../../source/util.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '../..'); - -test('util.readPkg - without packagePath', async t => { - const {pkg, rootDir: pkgDir} = await util.readPkg(); - - t.is(pkg.name, 'np'); - t.is(pkgDir, rootDir); -}); - -test('util.readPkg - with packagePath', async t => { - const fixtureDir = path.resolve(rootDir, 'test/fixtures/files/one-file'); - const {pkg, rootDir: pkgDir} = await util.readPkg(fixtureDir); - - t.is(pkg.name, 'foo'); - t.is(pkgDir, fixtureDir); -}); - -test('util.readPkg - no package.json', async t => { - await t.throwsAsync( - util.readPkg(temporaryDirectory()), - {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, - ); -}); - -test('util.npPkg', t => { - t.is(util.npPkg.name, 'np'); -}); - -test('util.npRootDir', t => { - t.is(util.npRootDir, rootDir); -}); - -const testJoinList = test.macro((t, list, expectations) => { - const output = util.joinList(list); - t.is(stripAnsi(output), expectations); -}); - -test('util.joinList - one item', testJoinList, ['foo'], '- foo'); - -test('util.joinList - two items', testJoinList, ['foo', 'bar'], '- foo\n- bar'); - -test('util.joinList - multiple items', testJoinList, ['foo', 'bar', 'baz'], '- foo\n- bar\n- baz'); - -const testEngineRanges = test.macro((t, engine, {above, below}) => { - const range = util.npPkg.engines[engine]; - - t.notThrows( - () => util.validateEngineVersionSatisfies(engine, above), // One above minimum - ); - - t.throws( - () => util.validateEngineVersionSatisfies(engine, below), // One below minimum - {message: `\`np\` requires ${engine} ${range}`}, - ); -}); - -test('util.validateEngineVersionSatisfies - node', testEngineRanges, 'node', {above: '16.7.0', below: '16.5.0'}); - -test('util.validateEngineVersionSatisfies - npm', testEngineRanges, 'npm', {above: '7.20.0', below: '7.18.0'}); - -test('util.validateEngineVersionSatisfies - git', testEngineRanges, 'git', {above: '2.12.0', below: '2.10.0'}); - -test('util.validateEngineVersionSatisfies - yarn', testEngineRanges, 'yarn', {above: '1.8.0', below: '1.6.0'}); - 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'}); + From 676bdfd6c9bc9d3f39d2b6d513085dbd07b99db6 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 20:54:54 -0500 Subject: [PATCH 39/63] move `git-util` tests to separate files --- test/git/check-if-file-git-ignored.js | 13 + test/git/commit-log-from-revision.js | 17 ++ test/git/default-branch.js | 37 +++ test/git/delete-tag.js | 17 ++ test/git/get-current-branch.js | 12 + test/git/has-upstream.js | 11 + test/git/integration.js | 223 ------------------ test/git/is-head-detached.js | 18 ++ test/git/latest-tag-or-first-commit.js | 37 +++ test/git/latest-tag.js | 23 ++ test/git/new-files-since-last-release.js | 39 +++ test/git/previous-tag-or-first-commit.js | 38 +++ test/git/push-graceful.js | 40 ++++ test/git/read-file-from-last-release.js | 16 ++ test/git/remove-last-commit.js | 21 ++ test/git/root.js | 17 ++ test/git/stub.js | 146 ------------ test/git/unit.js | 17 -- test/git/verify-recent-git-version.js | 24 ++ test/git/verify-remote-history-is-clean.js | 74 ++++++ test/git/verify-remote-is-valid.js | 27 +++ .../verify-tag-does-not-exist-on-remote.js | 28 +++ test/git/verify-working-tree-is-clean.js | 26 ++ 23 files changed, 535 insertions(+), 386 deletions(-) create mode 100644 test/git/check-if-file-git-ignored.js create mode 100644 test/git/commit-log-from-revision.js create mode 100644 test/git/default-branch.js create mode 100644 test/git/delete-tag.js create mode 100644 test/git/get-current-branch.js create mode 100644 test/git/has-upstream.js delete mode 100644 test/git/integration.js create mode 100644 test/git/is-head-detached.js create mode 100644 test/git/latest-tag-or-first-commit.js create mode 100644 test/git/latest-tag.js create mode 100644 test/git/new-files-since-last-release.js create mode 100644 test/git/previous-tag-or-first-commit.js create mode 100644 test/git/push-graceful.js create mode 100644 test/git/read-file-from-last-release.js create mode 100644 test/git/remove-last-commit.js create mode 100644 test/git/root.js delete mode 100644 test/git/stub.js delete mode 100644 test/git/unit.js create mode 100644 test/git/verify-recent-git-version.js create mode 100644 test/git/verify-remote-history-is-clean.js create mode 100644 test/git/verify-remote-is-valid.js create mode 100644 test/git/verify-tag-does-not-exist-on-remote.js create mode 100644 test/git/verify-working-tree-is-clean.js diff --git a/test/git/check-if-file-git-ignored.js b/test/git/check-if-file-git-ignored.js new file mode 100644 index 00000000..9983ddec --- /dev/null +++ b/test/git/check-if-file-git-ignored.js @@ -0,0 +1,13 @@ +import path from 'node:path'; +import test from 'ava'; +import {npRootDir} from '../../source/util.js'; +import {checkIfFileGitIgnored} from '../../source/git-util.js'; + +const npPkgPath = path.join(npRootDir, 'package.json'); + +test('np package.json not ignored, yarn.lock is', async t => { + t.false(await checkIfFileGitIgnored(npPkgPath)); + t.true(await checkIfFileGitIgnored(path.resolve(npRootDir, 'yarn.lock'))); +}); + +test.todo('throws'); diff --git a/test/git/commit-log-from-revision.js b/test/git/commit-log-from-revision.js new file mode 100644 index 00000000..e01381fa --- /dev/null +++ b/test/git/commit-log-from-revision.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns single commit', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git, $$}) => { + const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; + t.is(await git.commitLogFromRevision('v0.0.0'), `"added" ${lastCommitSha}`); +}); + +test.todo('returns multiple commits'); diff --git a/test/git/default-branch.js b/test/git/default-branch.js new file mode 100644 index 00000000..07fc1c6a --- /dev/null +++ b/test/git/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/delete-tag.js b/test/git/delete-tag.js new file mode 100644 index 00000000..f1e7ac79 --- /dev/null +++ b/test/git/delete-tag.js @@ -0,0 +1,17 @@ +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.todo('deletes given tag from a large list'); +test.todo('no tags'); diff --git a/test/git/get-current-branch.js b/test/git/get-current-branch.js new file mode 100644 index 00000000..14623264 --- /dev/null +++ b/test/git/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/has-upstream.js b/test/git/has-upstream.js new file mode 100644 index 00000000..0f4a4f71 --- /dev/null +++ b/test/git/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/integration.js b/test/git/integration.js deleted file mode 100644 index 748491db..00000000 --- a/test/git/integration.js +++ /dev/null @@ -1,223 +0,0 @@ -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('git-util.latestTag', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; -}, async ({t, testedModule: git}) => { - t.is(await git.latestTag(), 'v0.0.0'); -}); - -test('git-util.newFilesSinceLastRelease', createFixture, 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"`; -}, async ({t, testedModule: git, temporaryDir}) => { - const newFiles = await git.newFilesSinceLastRelease(temporaryDir); - t.deepEqual(newFiles.sort(), ['new', 'index.js'].sort()); -}); - -test('git-util.newFilesSinceLastRelease - no files', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; -}, async ({t, testedModule: git, temporaryDir}) => { - const newFiles = await git.newFilesSinceLastRelease(temporaryDir); - t.deepEqual(newFiles, []); -}); - -test('git-util.newFilesSinceLastRelease - use 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: git, temporaryDir}) => { - const newFiles = await git.newFilesSinceLastRelease(temporaryDir); - t.deepEqual(newFiles.sort(), ['index.js', 'package.json', '.gitignore'].sort()); -}); - -// TODO: failing, seems like issue with path.relative -test.failing('git-util.readFileFromLastRelease', createFixture, async ({t, $$}) => { - await $$`git tag v0.0.0`; - await t.context.createFile('unicorn.txt', 'unicorn'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, async ({t, testedModule: git}) => { - const file = await git.readFileFromLastRelease('unicorn.txt'); - t.is(file, 'unicorn'); -}); - -// TODO: `tagList` always has a minimum length of 1 -> `''.split('\n')` => `['']` -test.failing('git-util.previousTagOrFirstCommit - no tags', createFixture, () => {}, async ({t, testedModule: git}) => { - const result = await git.previousTagOrFirstCommit(); - t.is(result, undefined); -}); - -test('git-util.previousTagOrFirstCommit - one tag', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; -}, async ({t, testedModule: git, $$}) => { - const result = await git.previousTagOrFirstCommit(); - const {stdout: firstCommitMessage} = await getCommitMessage($$, result); - t.is(firstCommitMessage.trim(), '"init1"'); -}); - -// TODO: not sure why failing -test.failing('git-util.previousTagOrFirstCommit - two tags', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; - await $$`git tag v1.0.0`; -}, async ({t, testedModule: git}) => { - const result = await git.previousTagOrFirstCommit(); - t.is(result, 'v0.0.0'); -}); - -// TODO: git-util.previousTagOrFirstCommit - test fallback case - -test('git-util.latestTagOrFirstCommit - one tag', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; -}, async ({t, testedModule: git}) => { - const result = await git.latestTagOrFirstCommit(); - t.is(result, 'v0.0.0'); -}); - -// TODO: is this intended behavior? I'm not sure -test.failing('git-util.latestTagOrFirstCommit - two tags', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; - await $$`git tag v1.0.0`; -}, async ({t, testedModule: git}) => { - const result = await git.latestTagOrFirstCommit(); - t.is(result, 'v1.0.0'); -}); - -test('git-util.latestTagOrFirstCommit - no tags (fallback)', createFixture, async () => {}, async ({t, testedModule: git, $$}) => { - const result = await git.latestTagOrFirstCommit(); - const {stdout: firstCommitMessage} = await getCommitMessage($$, result); - t.is(firstCommitMessage.trim(), '"init1"'); -}); - -test('git-util.hasUpstream', createFixture, async () => {}, async ({t, testedModule: git}) => { - t.false(await git.hasUpstream()); -}); - -test('git-util.getCurrentBranch', createFixture, async ({$$}) => { - await $$`git switch -c unicorn`; -}, async ({t, testedModule: git}) => { - const currentBranch = await git.getCurrentBranch(); - t.is(currentBranch, 'unicorn'); -}); - -test('git-util.isHeadDetached - not detached', createFixture, async () => {}, async ({t, testedModule: git}) => { - t.false(await git.isHeadDetached()); -}); - -test('git-util.isHeadDetached - detached', createFixture, async ({$$}) => { - const {stdout: firstCommitSha} = await $$`git rev-list --max-parents=0 HEAD`; - await $$`git checkout ${firstCommitSha}`; -}, async ({t, testedModule: git}) => { - t.true(await git.isHeadDetached()); -}); - -test('git-util.verifyWorkingTreeIsClean - clean', createFixture, async ({t, $$}) => { - t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, async ({t, testedModule: git}) => { - await t.notThrowsAsync( - git.verifyWorkingTreeIsClean(), - ); -}); - -test('git-util.verifyWorkingTreeIsClean - not clean', createFixture, async ({t}) => { - t.context.createFile('index.js'); -}, async ({t, testedModule: git}) => { - await t.throwsAsync( - git.verifyWorkingTreeIsClean(), - {message: 'Unclean working tree. Commit or stash changes first.'}, - ); -}); - -// TODO: git-util.verifyWorkingTreeIsClean - test `git status --porcelain` failing - -test('git-util.verifyRemoteHistoryIsClean - no remote', createFixture, async () => {}, async ({t, testedModule: git}) => { - const result = await t.notThrowsAsync( - git.verifyRemoteHistoryIsClean(), - ); - - t.is(result, undefined); -}); - -test('git-util.verifyRemoteIsValid - no remote', createFixture, async () => {}, async ({t, testedModule: git}) => { - await t.throwsAsync( - git.verifyRemoteIsValid(), - {message: /^Git fatal error:/m}, - ); -}); - -test('git-util.defaultBranch - main', createFixture, async ({$$}) => { - await $$`git checkout -B main`; -}, async ({t, testedModule: git}) => { - t.is(await git.defaultBranch(), 'main'); -}); - -test('git-util.defaultBranch - master', createFixture, async ({$$}) => { - await $$`git checkout -B master`; - await $$`git update-ref -d refs/heads/main`; -}, async ({t, testedModule: git}) => { - t.is(await git.defaultBranch(), 'master'); -}); - -test('git-util.defaultBranch - 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: git}) => { - t.is(await git.defaultBranch(), 'gh-pages'); -}); - -test('git-util.defaultBranch - 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: git}) => { - await t.throwsAsync( - git.defaultBranch(), - {message: 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'}, - ); -}); - -test('git-util.commitLogFromRevision', createFixture, async ({t, $$}) => { - await $$`git tag v0.0.0`; - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, async ({t, testedModule: git, $$}) => { - const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; - t.is(await git.commitLogFromRevision('v0.0.0'), `"added" ${lastCommitSha}`); -}); - -test('git-util.deleteTag', createFixture, async ({$$}) => { - await $$`git tag v0.0.0`; - await $$`git tag v1.0.0`; -}, async ({t, testedModule: git, $$}) => { - await git.deleteTag('v1.0.0'); - const {stdout: tags} = await $$`git tag`; - t.is(tags, 'v0.0.0'); -}); - -test('git-util.removeLastCommit', createFixture, async ({t, $$}) => { - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, async ({t, testedModule: git, $$}) => { - const {stdout: commitsBefore} = await $$`git log --pretty="%s"`; - t.true(commitsBefore.includes('"added"')); - - await git.removeLastCommit(); - - const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; - t.false(commitsAfter.includes('"added"')); -}); diff --git a/test/git/is-head-detached.js b/test/git/is-head-detached.js new file mode 100644 index 00000000..888b55a5 --- /dev/null +++ b/test/git/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/latest-tag-or-first-commit.js b/test/git/latest-tag-or-first-commit.js new file mode 100644 index 00000000..f2769df6 --- /dev/null +++ b/test/git/latest-tag-or-first-commit.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'); + +// 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.createFile('new'); + await $$`git add new`; + await $$`git commit -m 'added'`; + + 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/latest-tag.js b/test/git/latest-tag.js new file mode 100644 index 00000000..175e28a8 --- /dev/null +++ b/test/git/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`; + + await t.context.createFile('new'); + await $$`git add new`; + await $$`git commit -m 'added'`; + + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v1.0.0'); +}); diff --git a/test/git/new-files-since-last-release.js b/test/git/new-files-since-last-release.js new file mode 100644 index 00000000..2878116d --- /dev/null +++ b/test/git/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 -A`; + 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/previous-tag-or-first-commit.js b/test/git/previous-tag-or-first-commit.js new file mode 100644 index 00000000..6127908e --- /dev/null +++ b/test/git/previous-tag-or-first-commit.js @@ -0,0 +1,38 @@ +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}`; + +// TODO: `tagList` always has a minimum length of 1 -> `''.split('\n')` => `['']` +test.failing('no tags', createFixture, () => {}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, undefined); +}); + +test('one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}, $$}) => { + const result = await previousTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + + t.is(firstCommitMessage.trim(), '"init1"'); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + + await t.context.createFile('new'); + await $$`git add new`; + await $$`git commit -m 'added'`; + + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test.todo('test fallback case'); diff --git a/test/git/push-graceful.js b/test/git/push-graceful.js new file mode 100644 index 00000000..97b6c14a --- /dev/null +++ b/test/git/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/read-file-from-last-release.js b/test/git/read-file-from-last-release.js new file mode 100644 index 00000000..b3bf67f5 --- /dev/null +++ b/test/git/read-file-from-last-release.js @@ -0,0 +1,16 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +// TODO: failing, seems like issue with path.relative +test.failing('returns content of a given file', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: git}) => { + const file = await git.readFileFromLastRelease('unicorn.txt'); + t.is(file, 'unicorn'); +}); diff --git a/test/git/remove-last-commit.js b/test/git/remove-last-commit.js new file mode 100644 index 00000000..4c5445fb --- /dev/null +++ b/test/git/remove-last-commit.js @@ -0,0 +1,21 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('removes most previous 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"')); +}); + +test.todo('test over tags'); diff --git a/test/git/root.js b/test/git/root.js new file mode 100644 index 00000000..8258043d --- /dev/null +++ b/test/git/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/stub.js b/test/git/stub.js deleted file mode 100644 index 25434094..00000000 --- a/test/git/stub.js +++ /dev/null @@ -1,146 +0,0 @@ -import test from 'ava'; -import {_createFixture} from '../_helpers/stub-execa.js'; - -/** @type {ReturnType>} */ -const createFixture = _createFixture('../../source/git-util.js', import.meta.url); - -test('git-util.verifyRemoteHistoryIsClean - unfetched changes', createFixture, [ - { - 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: git}) => { - await t.throwsAsync( - git.verifyRemoteHistoryIsClean(), - {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, - ); -}); - -test('git-util.verifyRemoteHistoryIsClean - unclean remote history', createFixture, [ - { - 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: git}) => { - await t.throwsAsync( - git.verifyRemoteHistoryIsClean(), - {message: 'Remote history differs. Please pull changes.'}, - ); -}); - -test('git-util.verifyRemoteHistoryIsClean - clean fetched remote history', createFixture, [ - { - 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: git}) => { - await t.notThrowsAsync( - git.verifyRemoteHistoryIsClean(), - ); -}); - -test('git-util.verifyRemoteIsValid - has remote', createFixture, [{ - command: 'git ls-remote origin HEAD', - exitCode: 0, -}], async ({t, testedModule: git}) => { - await t.notThrowsAsync( - git.verifyRemoteIsValid(), - ); -}); - -test('git-util.verifyTagDoesNotExistOnRemote - exists', createFixture, [{ - command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', - stdout: '123456789', // Some hash -}], async ({t, testedModule: git}) => { - await t.throwsAsync( - git.verifyTagDoesNotExistOnRemote('v0.0.0'), - {message: 'Git tag `v0.0.0` already exists.'}, - ); -}); - -test('git-util.verifyTagDoesNotExistOnRemote - does not exist', createFixture, [{ - command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', - exitCode: 1, - stderr: '', - stdout: '', -}], async ({t, testedModule: git}) => { - await t.notThrowsAsync( - git.verifyTagDoesNotExistOnRemote('v0.0.0'), - ); -}); - -// TODO: git-util.verifyTagDoesNotExistOnRemote - test when tagExistsOnRemote() errors - -test('git-util.pushGraceful - succeeds', createFixture, [{ - command: 'git push --follow-tags', - exitCode: 0, -}], async ({t, testedModule: git}) => { - await t.notThrowsAsync( - git.pushGraceful(), - ); -}); - -test('git-util.pushGraceful - 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: git}) => { - const {pushed, reason} = await git.pushGraceful(true); - - t.is(pushed, 'tags'); - t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); -}); - -test('git-util.pushGraceful - throws', createFixture, [{ - command: 'git push --follow-tags', - exitCode: 1, -}], async ({t, testedModule: git}) => { - await t.throwsAsync( - git.pushGraceful(false), - ); -}); - -test('git-util.verifyRecentGitVersion - satisfied', createFixture, [{ - command: 'git version', - stdout: 'git version 2.12.0', // One higher than minimum -}], async ({t, testedModule: git}) => { - await t.notThrowsAsync( - git.verifyRecentGitVersion(), - ); -}); - -test('git-util.verifyRecentGitVersion - not satisfied', createFixture, [{ - command: 'git version', - stdout: 'git version 2.10.0', // One lower than minimum -}], async ({t, testedModule: git}) => { - await t.throwsAsync( - git.verifyRecentGitVersion(), - {message: '`np` requires git >=2.11.0'}, - ); -}); - diff --git a/test/git/unit.js b/test/git/unit.js deleted file mode 100644 index df3e8101..00000000 --- a/test/git/unit.js +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'node:path'; -import test from 'ava'; -import {npRootDir} from '../../source/util.js'; -import * as git from '../../source/git-util.js'; - -const npPkgPath = path.join(npRootDir, 'package.json'); - -test('git-util.root', async t => { - t.is(await git.root(), npRootDir); -}); - -test('git-util.checkIfFileGitIgnored', async t => { - t.false(await git.checkIfFileGitIgnored(npPkgPath)); - t.true(await git.checkIfFileGitIgnored(path.resolve(npRootDir, 'yarn.lock'))); -}); - -// TODO: git-util.checkIfFileGitIgnored - test throws diff --git a/test/git/verify-recent-git-version.js b/test/git/verify-recent-git-version.js new file mode 100644 index 00000000..2a45601e --- /dev/null +++ b/test/git/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/verify-remote-history-is-clean.js b/test/git/verify-remote-history-is-clean.js new file mode 100644 index 00000000..33e2e2ee --- /dev/null +++ b/test/git/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/verify-remote-is-valid.js b/test/git/verify-remote-is-valid.js new file mode 100644 index 00000000..de02eb7c --- /dev/null +++ b/test/git/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/verify-tag-does-not-exist-on-remote.js b/test/git/verify-tag-does-not-exist-on-remote.js new file mode 100644 index 00000000..fe683006 --- /dev/null +++ b/test/git/verify-tag-does-not-exist-on-remote.js @@ -0,0 +1,28 @@ +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'), + ); +}); + +test.todo('tagExistsOnRemote() errors'); diff --git a/test/git/verify-working-tree-is-clean.js b/test/git/verify-working-tree-is-clean.js new file mode 100644 index 00000000..3b5d3f0a --- /dev/null +++ b/test/git/verify-working-tree-is-clean.js @@ -0,0 +1,26 @@ +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, $$}) => { + t.context.createFile('index.js'); + await $$`git add index.js`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.notThrowsAsync( + verifyWorkingTreeIsClean(), + ); +}); + +test('not clean', createFixture, async ({t}) => { + t.context.createFile('index.js'); +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.throwsAsync( + verifyWorkingTreeIsClean(), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); +}); + +test.todo('add test for when `git status --porcelain` fails'); From 2eefd9694b15abfe4658dee53942f1179a9f7729 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 21:13:11 -0500 Subject: [PATCH 40/63] use `git add .` in tests --- test/git/read-file-from-last-release.js | 4 +++- test/git/verify-working-tree-is-clean.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/git/read-file-from-last-release.js b/test/git/read-file-from-last-release.js index b3bf67f5..4e729807 100644 --- a/test/git/read-file-from-last-release.js +++ b/test/git/read-file-from-last-release.js @@ -8,9 +8,11 @@ const createFixture = _createFixture('../../source/git-util.js'); test.failing('returns content of a given file', createFixture, async ({t, $$}) => { await $$`git tag v0.0.0`; await t.context.createFile('unicorn.txt', 'unicorn'); - await $$`git add -A`; + await $$`git add .`; await $$`git commit -m "added"`; }, async ({t, testedModule: git}) => { const file = await git.readFileFromLastRelease('unicorn.txt'); t.is(file, 'unicorn'); }); + +test.todo('no previous release'); diff --git a/test/git/verify-working-tree-is-clean.js b/test/git/verify-working-tree-is-clean.js index 3b5d3f0a..fa4115eb 100644 --- a/test/git/verify-working-tree-is-clean.js +++ b/test/git/verify-working-tree-is-clean.js @@ -6,7 +6,7 @@ const createFixture = _createFixture('../../source/git-util.js'); test('clean', createFixture, async ({t, $$}) => { t.context.createFile('index.js'); - await $$`git add index.js`; + await $$`git add .`; await $$`git commit -m "added"`; }, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { await t.notThrowsAsync( From 80cfc9d086fee70ba7815ac124ac2df8cb9a2c70 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 21:28:25 -0500 Subject: [PATCH 41/63] fix: await create file --- test/git/verify-working-tree-is-clean.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/git/verify-working-tree-is-clean.js b/test/git/verify-working-tree-is-clean.js index fa4115eb..93f72fcb 100644 --- a/test/git/verify-working-tree-is-clean.js +++ b/test/git/verify-working-tree-is-clean.js @@ -5,7 +5,7 @@ import {_createFixture} from '../_helpers/integration-test.js'; const createFixture = _createFixture('../../source/git-util.js'); test('clean', createFixture, async ({t, $$}) => { - t.context.createFile('index.js'); + await t.context.createFile('index.js'); await $$`git add .`; await $$`git commit -m "added"`; }, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { @@ -15,7 +15,7 @@ test('clean', createFixture, async ({t, $$}) => { }); test('not clean', createFixture, async ({t}) => { - t.context.createFile('index.js'); + await t.context.createFile('index.js'); }, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { await t.throwsAsync( verifyWorkingTreeIsClean(), From 86c3ffd66954a9eb26f6c3ab52e3c3efe98543c8 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 23:05:23 -0500 Subject: [PATCH 42/63] tests(`hyperlink`): use `esmock` --- test/util/hyperlinks.js | 61 +++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index 9a8f8e8d..a558c0a8 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -1,59 +1,78 @@ import test from 'ava'; -import sinon from 'sinon'; +import esmock from 'esmock'; 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(); +const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { + const mockedTerminalLink = Object.assign(terminalLink, { + isSupported: linksSupported, + }); -test.afterEach(() => { - sandbox.restore(); -}); + /** @type {typeof import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', { + 'terminal-link': mockedTerminalLink, + }); -// TODO: use esmock, get rid of serial on tests -const mockTerminalLinkUnsupported = () => - sandbox.stub(terminalLink, 'isSupported').value(false); + await assertions({t, util}); +}); -test('linkifyIssues correctly links issues', t => { +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', t => { +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.serial('linkifyIssues returns raw message if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); +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', t => { +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', t => { +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.serial('linkifyCommit returns raw commit hash if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); +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', t => { +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.serial('linkifyCommitRange returns raw commitRange if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); +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); }); -// TODO: linkifyCommitRange - L55 - returns with `compare` +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;;'); +}); From 213d3749f517c4242e81d64658a48ca3097141e4 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 23:43:53 -0500 Subject: [PATCH 43/63] fix(`hyperlinks`): mark tests as serial --- test/util/hyperlinks.js | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index a558c0a8..56dd4568 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -7,19 +7,23 @@ const MOCK_COMMIT_HASH = '5063f8a'; const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { - const mockedTerminalLink = Object.assign(terminalLink, { - isSupported: linksSupported, - }); - + // TODO: copy terminalLink to allow concurrent tests /** @type {typeof import('../../source/util.js')} */ const util = await esmock('../../source/util.js', { - 'terminal-link': mockedTerminalLink, + 'terminal-link': Object.assign(terminalLink, { + isSupported: linksSupported, + }), + }, { + 'supports-hyperlinks': { + stdout: linksSupported, + stderr: linksSupported, + }, }); await assertions({t, util}); }); -test('linkifyIssues correctly links issues', verifyLinks, { +test.serial('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;;'); @@ -27,51 +31,51 @@ test('linkifyIssues correctly links issues', verifyLinks, { 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, { +test.serial('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, { +test.serial('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, { +test.serial('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, { +test.serial('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, { +test.serial('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, { +test.serial('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, { +test.serial('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, { +test.serial('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;;'); From f869534489d34d826cd0e5aa004adc08e6ff36d9 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Sun, 30 Jul 2023 23:49:40 -0500 Subject: [PATCH 44/63] tests(`hyperlinks`): properly mock `terminal-link` --- test/util/hyperlinks.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index 56dd4568..cec476ca 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -1,6 +1,5 @@ import test from 'ava'; import esmock from 'esmock'; -import terminalLink from 'terminal-link'; const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; const MOCK_COMMIT_HASH = '5063f8a'; @@ -9,11 +8,7 @@ const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { // TODO: copy terminalLink to allow concurrent tests /** @type {typeof import('../../source/util.js')} */ - const util = await esmock('../../source/util.js', { - 'terminal-link': Object.assign(terminalLink, { - isSupported: linksSupported, - }), - }, { + const util = await esmock('../../source/util.js', {}, { 'supports-hyperlinks': { stdout: linksSupported, stderr: linksSupported, @@ -23,7 +18,7 @@ const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { await assertions({t, util}); }); -test.serial('linkifyIssues correctly links issues', verifyLinks, { +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;;'); @@ -31,51 +26,51 @@ test.serial('linkifyIssues correctly links issues', verifyLinks, { 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.serial('linkifyIssues returns raw message if url is not provided', verifyLinks, { +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.serial('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { +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.serial('linkifyCommit correctly links commits', verifyLinks, { +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.serial('linkifyCommit returns raw commit hash if url is not provided', verifyLinks, { +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.serial('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { +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.serial('linkifyCommitRange returns raw commitRange if url is not provided', verifyLinks, { +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.serial('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { +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.serial('linkifyCommitRange correctly links commit range', verifyLinks, { +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;;'); From a1618c1c825ecb6fcf448afb1189b351ed1b31e4 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 07:51:54 -0500 Subject: [PATCH 45/63] rename `test/git` to `test/git-util` --- test/{git => git-util}/check-if-file-git-ignored.js | 0 test/{git => git-util}/commit-log-from-revision.js | 0 test/{git => git-util}/default-branch.js | 0 test/{git => git-util}/delete-tag.js | 0 test/{git => git-util}/get-current-branch.js | 0 test/{git => git-util}/has-upstream.js | 0 test/{git => git-util}/is-head-detached.js | 0 test/{git => git-util}/latest-tag-or-first-commit.js | 0 test/{git => git-util}/latest-tag.js | 0 test/{git => git-util}/new-files-since-last-release.js | 0 test/{git => git-util}/previous-tag-or-first-commit.js | 0 test/{git => git-util}/push-graceful.js | 0 test/{git => git-util}/read-file-from-last-release.js | 0 test/{git => git-util}/remove-last-commit.js | 0 test/{git => git-util}/root.js | 0 test/{git => git-util}/verify-recent-git-version.js | 0 test/{git => git-util}/verify-remote-history-is-clean.js | 0 test/{git => git-util}/verify-remote-is-valid.js | 0 test/{git => git-util}/verify-tag-does-not-exist-on-remote.js | 0 test/{git => git-util}/verify-working-tree-is-clean.js | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename test/{git => git-util}/check-if-file-git-ignored.js (100%) rename test/{git => git-util}/commit-log-from-revision.js (100%) rename test/{git => git-util}/default-branch.js (100%) rename test/{git => git-util}/delete-tag.js (100%) rename test/{git => git-util}/get-current-branch.js (100%) rename test/{git => git-util}/has-upstream.js (100%) rename test/{git => git-util}/is-head-detached.js (100%) rename test/{git => git-util}/latest-tag-or-first-commit.js (100%) rename test/{git => git-util}/latest-tag.js (100%) rename test/{git => git-util}/new-files-since-last-release.js (100%) rename test/{git => git-util}/previous-tag-or-first-commit.js (100%) rename test/{git => git-util}/push-graceful.js (100%) rename test/{git => git-util}/read-file-from-last-release.js (100%) rename test/{git => git-util}/remove-last-commit.js (100%) rename test/{git => git-util}/root.js (100%) rename test/{git => git-util}/verify-recent-git-version.js (100%) rename test/{git => git-util}/verify-remote-history-is-clean.js (100%) rename test/{git => git-util}/verify-remote-is-valid.js (100%) rename test/{git => git-util}/verify-tag-does-not-exist-on-remote.js (100%) rename test/{git => git-util}/verify-working-tree-is-clean.js (100%) diff --git a/test/git/check-if-file-git-ignored.js b/test/git-util/check-if-file-git-ignored.js similarity index 100% rename from test/git/check-if-file-git-ignored.js rename to test/git-util/check-if-file-git-ignored.js diff --git a/test/git/commit-log-from-revision.js b/test/git-util/commit-log-from-revision.js similarity index 100% rename from test/git/commit-log-from-revision.js rename to test/git-util/commit-log-from-revision.js diff --git a/test/git/default-branch.js b/test/git-util/default-branch.js similarity index 100% rename from test/git/default-branch.js rename to test/git-util/default-branch.js diff --git a/test/git/delete-tag.js b/test/git-util/delete-tag.js similarity index 100% rename from test/git/delete-tag.js rename to test/git-util/delete-tag.js diff --git a/test/git/get-current-branch.js b/test/git-util/get-current-branch.js similarity index 100% rename from test/git/get-current-branch.js rename to test/git-util/get-current-branch.js diff --git a/test/git/has-upstream.js b/test/git-util/has-upstream.js similarity index 100% rename from test/git/has-upstream.js rename to test/git-util/has-upstream.js diff --git a/test/git/is-head-detached.js b/test/git-util/is-head-detached.js similarity index 100% rename from test/git/is-head-detached.js rename to test/git-util/is-head-detached.js diff --git a/test/git/latest-tag-or-first-commit.js b/test/git-util/latest-tag-or-first-commit.js similarity index 100% rename from test/git/latest-tag-or-first-commit.js rename to test/git-util/latest-tag-or-first-commit.js diff --git a/test/git/latest-tag.js b/test/git-util/latest-tag.js similarity index 100% rename from test/git/latest-tag.js rename to test/git-util/latest-tag.js diff --git a/test/git/new-files-since-last-release.js b/test/git-util/new-files-since-last-release.js similarity index 100% rename from test/git/new-files-since-last-release.js rename to test/git-util/new-files-since-last-release.js diff --git a/test/git/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js similarity index 100% rename from test/git/previous-tag-or-first-commit.js rename to test/git-util/previous-tag-or-first-commit.js diff --git a/test/git/push-graceful.js b/test/git-util/push-graceful.js similarity index 100% rename from test/git/push-graceful.js rename to test/git-util/push-graceful.js diff --git a/test/git/read-file-from-last-release.js b/test/git-util/read-file-from-last-release.js similarity index 100% rename from test/git/read-file-from-last-release.js rename to test/git-util/read-file-from-last-release.js diff --git a/test/git/remove-last-commit.js b/test/git-util/remove-last-commit.js similarity index 100% rename from test/git/remove-last-commit.js rename to test/git-util/remove-last-commit.js diff --git a/test/git/root.js b/test/git-util/root.js similarity index 100% rename from test/git/root.js rename to test/git-util/root.js diff --git a/test/git/verify-recent-git-version.js b/test/git-util/verify-recent-git-version.js similarity index 100% rename from test/git/verify-recent-git-version.js rename to test/git-util/verify-recent-git-version.js diff --git a/test/git/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js similarity index 100% rename from test/git/verify-remote-history-is-clean.js rename to test/git-util/verify-remote-history-is-clean.js diff --git a/test/git/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js similarity index 100% rename from test/git/verify-remote-is-valid.js rename to test/git-util/verify-remote-is-valid.js diff --git a/test/git/verify-tag-does-not-exist-on-remote.js b/test/git-util/verify-tag-does-not-exist-on-remote.js similarity index 100% rename from test/git/verify-tag-does-not-exist-on-remote.js rename to test/git-util/verify-tag-does-not-exist-on-remote.js diff --git a/test/git/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js similarity index 100% rename from test/git/verify-working-tree-is-clean.js rename to test/git-util/verify-working-tree-is-clean.js From 784b2cdaec66ba8e859accc5373fe18e20e71fc5 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 11:33:11 -0500 Subject: [PATCH 46/63] tests(`git-util`): finish testing --- source/git-util.js | 7 ++-- source/release-task-helper.js | 2 +- test/_helpers/integration-test.d.ts | 18 ++++++--- test/_helpers/integration-test.js | 23 ++++++++++++ test/_helpers/util.js | 7 ++++ test/git-util/check-if-file-git-ignored.js | 21 +++++++---- test/git-util/commit-log-from-revision.js | 31 ++++++++++++---- test/git-util/delete-tag.js | 27 +++++++++++++- test/git-util/has-upstream.js | 10 ++--- test/git-util/is-head-detached.js | 10 ++--- test/git-util/latest-tag-or-first-commit.js | 20 ++++------ test/git-util/latest-tag.js | 14 +++---- test/git-util/new-files-since-last-release.js | 2 +- test/git-util/previous-tag-or-first-commit.js | 35 +++++++++++------- test/git-util/read-file-from-last-release.js | 37 ++++++++++++++++--- test/git-util/remove-last-commit.js | 4 +- test/git-util/root.js | 10 ++--- ...verify-current-branch-is-release-branch.js | 22 +++++++++++ .../verify-remote-history-is-clean.js | 16 ++++---- test/git-util/verify-remote-is-valid.js | 16 ++++---- .../verify-tag-does-not-exist-on-remote.js | 2 - test/git-util/verify-working-tree-is-clean.js | 2 - test/util/hyperlinks.js | 1 - 23 files changed, 232 insertions(+), 105 deletions(-) create mode 100644 test/git-util/verify-current-branch-is-release-branch.js diff --git a/source/git-util.js b/source/git-util.js index 6105634b..cfe36356 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -33,15 +33,16 @@ 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 () => { - // Returns the list of tags, sorted by creation date in ascending order. const {stdout} = await execa('git', ['tag', '--sort=creatordate']); - return stdout.split('\n'); + return stdout ? stdout.split('\n') : []; }; const firstCommit = async () => { diff --git a/source/release-task-helper.js b/source/release-task-helper.js index c4234723..5fde61d6 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,7 +4,7 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { - const newVersion = new Version(pkg.version, options.version); + const newVersion = new Version(pkg.version).setFrom(options.version); let tag = await getTagVersionPrefix(options) + newVersion; const isPrerelease = new Version(options.version).isPrerelease(); diff --git a/test/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts index 54afcf0a..5bbd4f81 100644 --- a/test/_helpers/integration-test.d.ts +++ b/test/_helpers/integration-test.d.ts @@ -1,14 +1,24 @@ 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; + t: ExecutionContext; $$: Execa$; temporaryDir: string; }]; type AssertionsFnParameters = [{ - t: ExecutionContext; + t: ExecutionContext; testedModule: MockType; $$: Execa$; temporaryDir: string; @@ -17,8 +27,6 @@ type AssertionsFnParameters = [{ export type CreateFixtureMacro = Macro<[ commands: (...arguments_: CommandsFnParameters) => Promise, assertions: (...arguments_: AssertionsFnParameters) => Promise, -], { - createFile: (file: string, content?: string) => Promise; -}>; +], Context>; export function _createFixture(source: string): CreateFixtureMacro; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index ed3f5cb7..85cb7751 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -1,4 +1,5 @@ /* 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'; @@ -23,7 +24,29 @@ export const createIntegrationTest = async (t, assertions) => { await createEmptyGitRepo($$, temporaryDir); + t.context.firstCommitMessage = '"init1"'; // From createEmptyGitRepo + + // 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}); }); }; diff --git a/test/_helpers/util.js b/test/_helpers/util.js index 1f801bb6..60600052 100644 --- a/test/_helpers/util.js +++ b/test/_helpers/util.js @@ -1,5 +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/git-util/check-if-file-git-ignored.js b/test/git-util/check-if-file-git-ignored.js index 9983ddec..1c5ba52b 100644 --- a/test/git-util/check-if-file-git-ignored.js +++ b/test/git-util/check-if-file-git-ignored.js @@ -1,13 +1,20 @@ -import path from 'node:path'; import test from 'ava'; -import {npRootDir} from '../../source/util.js'; +import {temporaryDirectory} from 'tempy'; import {checkIfFileGitIgnored} from '../../source/git-util.js'; -const npPkgPath = path.join(npRootDir, 'package.json'); +test('returns true for ignored files', async t => { + t.true(await checkIfFileGitIgnored('yarn.lock')); +}); -test('np package.json not ignored, yarn.lock is', async t => { - t.false(await checkIfFileGitIgnored(npPkgPath)); - t.true(await checkIfFileGitIgnored(path.resolve(npRootDir, 'yarn.lock'))); +test('returns false for non-ignored files', async t => { + t.false(await checkIfFileGitIgnored('package.json')); }); -test.todo('throws'); +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 index e01381fa..bf0a780b 100644 --- a/test/git-util/commit-log-from-revision.js +++ b/test/git-util/commit-log-from-revision.js @@ -1,17 +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 ({t, $$}) => { +test('returns single commit', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { await $$`git tag v0.0.0`; - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, async ({t, testedModule: git, $$}) => { - const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; - t.is(await git.commitLogFromRevision('v0.0.0'), `"added" ${lastCommitSha}`); + const {sha, commitMessage} = await t.context.commitNewFile(); + + t.is(await commitLogFromRevision('v0.0.0'), `${commitMessage} ${sha}`); }); -test.todo('returns multiple commits'); +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/delete-tag.js b/test/git-util/delete-tag.js index f1e7ac79..15c54016 100644 --- a/test/git-util/delete-tag.js +++ b/test/git-util/delete-tag.js @@ -10,8 +10,31 @@ test('deletes given tag', createFixture, async ({$$}) => { }, async ({t, testedModule: {deleteTag}, $$}) => { await deleteTag('v1.0.0'); const {stdout: tags} = await $$`git tag`; + t.is(tags, 'v0.0.0'); }); -test.todo('deletes given tag from a large list'); -test.todo('no tags'); +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/has-upstream.js b/test/git-util/has-upstream.js index 0f4a4f71..43e67aec 100644 --- a/test/git-util/has-upstream.js +++ b/test/git-util/has-upstream.js @@ -4,8 +4,8 @@ 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()); - }, -); +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 index 888b55a5..85de14a2 100644 --- a/test/git-util/is-head-detached.js +++ b/test/git-util/is-head-detached.js @@ -4,11 +4,11 @@ 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('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`; diff --git a/test/git-util/latest-tag-or-first-commit.js b/test/git-util/latest-tag-or-first-commit.js index f2769df6..526938c4 100644 --- a/test/git-util/latest-tag-or-first-commit.js +++ b/test/git-util/latest-tag-or-first-commit.js @@ -16,22 +16,18 @@ test('one tag', createFixture, async ({$$}) => { test('two tags', createFixture, async ({t, $$}) => { await $$`git tag v0.0.0`; - - await t.context.createFile('new'); - await $$`git add new`; - await $$`git commit -m 'added'`; - + 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); +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"'); - }, -); + t.is(firstCommitMessage.trim(), '"init1"'); +}); diff --git a/test/git-util/latest-tag.js b/test/git-util/latest-tag.js index 175e28a8..9c876f85 100644 --- a/test/git-util/latest-tag.js +++ b/test/git-util/latest-tag.js @@ -12,12 +12,12 @@ test('returns latest tag', createFixture, async ({$$}) => { test('returns latest tag - multiple set', createFixture, async ({t, $$}) => { await $$`git tag v0.0.0`; - - await t.context.createFile('new'); - await $$`git add new`; - await $$`git commit -m 'added'`; - - await $$`git tag v1.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(), 'v1.0.0'); + 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 index 2878116d..01ceb737 100644 --- a/test/git-util/new-files-since-last-release.js +++ b/test/git-util/new-files-since-last-release.js @@ -8,7 +8,7 @@ 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 -A`; + await $$`git add .`; await $$`git commit -m "added"`; }, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { const newFiles = await newFilesSinceLastRelease(temporaryDir); diff --git a/test/git-util/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js index 6127908e..53287bbd 100644 --- a/test/git-util/previous-tag-or-first-commit.js +++ b/test/git-util/previous-tag-or-first-commit.js @@ -4,35 +4,42 @@ 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}`; - -// TODO: `tagList` always has a minimum length of 1 -> `''.split('\n')` => `['']` -test.failing('no tags', createFixture, () => {}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { +test('no tags', createFixture, () => { + // +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { const result = await previousTagOrFirstCommit(); t.is(result, undefined); }); -test('one tag', createFixture, async ({$$}) => { +test('one tag - fallback to first commit', createFixture, async ({$$}) => { await $$`git tag v0.0.0`; -}, async ({t, testedModule: {previousTagOrFirstCommit}, $$}) => { +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { const result = await previousTagOrFirstCommit(); - const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + const commitMessage = await t.context.getCommitMessage(result); - t.is(firstCommitMessage.trim(), '"init1"'); + t.is(commitMessage, t.context.firstCommitMessage); }); test('two tags', createFixture, async ({t, $$}) => { await $$`git tag v0.0.0`; - - await t.context.createFile('new'); - await $$`git add new`; - await $$`git commit -m 'added'`; - + 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/read-file-from-last-release.js b/test/git-util/read-file-from-last-release.js index 4e729807..7d945f63 100644 --- a/test/git-util/read-file-from-last-release.js +++ b/test/git-util/read-file-from-last-release.js @@ -4,15 +4,40 @@ import {_createFixture} from '../_helpers/integration-test.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/git-util.js'); -// TODO: failing, seems like issue with path.relative -test.failing('returns content of a given file', createFixture, async ({t, $$}) => { +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: git}) => { - const file = await git.readFileFromLastRelease('unicorn.txt'); - t.is(file, 'unicorn'); +}, 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./}, + ); }); -test.todo('no previous release'); +// 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 index 4c5445fb..de7d794f 100644 --- a/test/git-util/remove-last-commit.js +++ b/test/git-util/remove-last-commit.js @@ -4,7 +4,7 @@ import {_createFixture} from '../_helpers/integration-test.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/git-util.js'); -test('removes most previous commit', createFixture, async ({t, $$}) => { +test('removes latest commit', createFixture, async ({t, $$}) => { await t.context.createFile('index.js'); await $$`git add -A`; await $$`git commit -m "added"`; @@ -17,5 +17,3 @@ test('removes most previous commit', createFixture, async ({t, $$}) => { const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; t.false(commitsAfter.includes('"added"')); }); - -test.todo('test over tags'); diff --git a/test/git-util/root.js b/test/git-util/root.js index 8258043d..6689df70 100644 --- a/test/git-util/root.js +++ b/test/git-util/root.js @@ -10,8 +10,8 @@ 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); - }, -); +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-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js index 33e2e2ee..14fd8ef1 100644 --- a/test/git-util/verify-remote-history-is-clean.js +++ b/test/git-util/verify-remote-history-is-clean.js @@ -63,12 +63,12 @@ test('clean fetched remote history', createStubFixture, [ ); }); -test('no remote', createIntegrationFixture, async () => {}, - async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { - const result = await t.notThrowsAsync( - verifyRemoteHistoryIsClean(), - ); +test('no remote', createIntegrationFixture, async () => { + // +}, async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + const result = await t.notThrowsAsync( + verifyRemoteHistoryIsClean(), + ); - t.is(result, undefined); - }, -); + t.is(result, undefined); +}); diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js index de02eb7c..fce75912 100644 --- a/test/git-util/verify-remote-is-valid.js +++ b/test/git-util/verify-remote-is-valid.js @@ -17,11 +17,11 @@ test('has remote', createStubFixture, [{ ); }); -test('no remote', createIntegrationFixture, async () => {}, - async ({t, testedModule: {verifyRemoteIsValid}}) => { - await t.throwsAsync( - verifyRemoteIsValid(), - {message: /^Git fatal error:/m}, - ); - }, -); +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 index fe683006..216c9d02 100644 --- a/test/git-util/verify-tag-does-not-exist-on-remote.js +++ b/test/git-util/verify-tag-does-not-exist-on-remote.js @@ -24,5 +24,3 @@ test('does not exist', createFixture, [{ verifyTagDoesNotExistOnRemote('v0.0.0'), ); }); - -test.todo('tagExistsOnRemote() errors'); diff --git a/test/git-util/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js index 93f72fcb..d1fc0774 100644 --- a/test/git-util/verify-working-tree-is-clean.js +++ b/test/git-util/verify-working-tree-is-clean.js @@ -22,5 +22,3 @@ test('not clean', createFixture, async ({t}) => { {message: 'Unclean working tree. Commit or stash changes first.'}, ); }); - -test.todo('add test for when `git status --porcelain` fails'); diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index cec476ca..8ddfb6fe 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -6,7 +6,6 @@ const MOCK_COMMIT_HASH = '5063f8a'; const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { - // TODO: copy terminalLink to allow concurrent tests /** @type {typeof import('../../source/util.js')} */ const util = await esmock('../../source/util.js', {}, { 'supports-hyperlinks': { From 53657bfb42bfeda4c50e42931657e07e23222765 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 12:19:13 -0500 Subject: [PATCH 47/63] tests(`inquirer`): log debug messages with AVA --- test/_helpers/mock-inquirer.js | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 52300fc6..7cf4c85d 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -7,9 +7,6 @@ import mapObject from 'map-obj'; // NOTE: This only handles prompts of type 'input', 'list', and 'confirm'. If other prompt types are added, they must be implemented here. // Based on https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 -// TODO: log with AVA instead (NODE_DEBUG-'np-test') -const log = debuglog('np-test'); - /** @typedef {import('ava').ExecutionContext>} ExecutionContext */ /** @typedef {string | boolean} ShortAnswer */ /** @typedef {Record<'input' | 'error', string> | Record<'choice', string> | Record<'confirm', boolean>} LongAnswer */ @@ -37,23 +34,23 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { prompts = promptsObject; } - log('prompts:', Object.keys(prompts)); + 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) { - log(`skipping prompt '${name}'`); + t.log(`skipping prompt '${name}'`); continue; } if (is.function_(prompt.when) && !prompt.when(answers)) { - log(`skipping prompt '${name}'`); + t.log(`skipping prompt '${name}'`); continue; } } - log(`getting input for prompt '${name}'`); + t.log(`getting input for prompt '${name}'`); const setValue = value => { if (prompt.validate) { @@ -71,16 +68,16 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { } if (is.string(value)) { - log(`filtering value '${value}' for prompt '${name}'`); + t.log(`filtering value '${value}' for prompt '${name}'`); } else { - log(`filtering value for prompt '${name}':`, value); + t.log(`filtering value for prompt '${name}':`, value); } answers[name] = prompt.filter ? prompt.filter(value) // eslint-disable-line unicorn/no-array-callback-reference : value; - log(`got value '${answers[name]}' for prompt '${name}'`); + t.log(`got value '${answers[name]}' for prompt '${name}'`); }; /** @param {Answer} input */ @@ -96,7 +93,7 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { choices = prompt.choices; } - log(`choices for prompt '${name}':`, choices); + t.log(`choices for prompt '${name}':`, choices); const value = choices.find(choice => { if (is.object(choice)) { @@ -122,9 +119,9 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { } if (is.string(input)) { - log(`found input for prompt '${name}': '${input}'`); + t.log(`found input for prompt '${name}': '${input}'`); } else { - log(`found input for prompt '${name}':`, input); + t.log(`found input for prompt '${name}':`, input); } /** @param {Answer} input */ @@ -193,7 +190,16 @@ export const mockInquirer = async ({t, answers, mocks = {}, logs = []}) => { /** @type {import('../../source/ui.js')} */ const ui = await esmock('../../source/ui.js', import.meta.url, { inquirer: { - prompt: async prompts => mockPrompt({t, inputAnswers: answers, prompts}), + 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), From 44a045ad4de72b14346c945628d3744632c2715c Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 12:50:15 -0500 Subject: [PATCH 48/63] handle todos, style tweaks --- readme.md | 2 +- source/cli-implementation.js | 3 -- source/config.js | 2 +- source/index.js | 2 +- source/npm/util.js | 2 +- source/util.js | 29 ++++---------- source/version.js | 6 +-- test/cli.js | 1 - test/npm/handle-npm-error.js | 2 - test/tasks/prerequisite-tasks.js | 4 +- test/util/get-new-dependencies.js | 2 +- test/util/get-pre-release-prefix.js | 62 +++++++++++++++++++++++++++++ test/util/get-tag-version-prefix.js | 52 ++++++++++++++++++++++++ test/util/prefix.js | 25 ------------ test/util/preid.js | 16 -------- test/version.js | 4 +- 16 files changed, 132 insertions(+), 82 deletions(-) create mode 100644 test/util/get-pre-release-prefix.js create mode 100644 test/util/get-tag-version-prefix.js delete mode 100644 test/util/prefix.js delete mode 100644 test/util/preid.js diff --git a/readme.md b/readme.md index bdb47ded..bc1fb62f 100644 --- a/readme.md +++ b/readme.md @@ -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) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 05d0aec2..d9db4b2e 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -113,12 +113,9 @@ try { ...cli.flags, }; - console.log(flags); - // Workaround for unintended auto-casing behavior from `meow`. if ('2Fa' in flags) { flags['2fa'] = flags['2Fa']; - // TODO: delete flags['2Fa']? } const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; 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/index.js b/source/index.js index 34dcc16f..f11e5a6b 100644 --- a/source/index.js +++ b/source/index.js @@ -83,7 +83,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { if (publishStatus === 'FAILED') { await rollback(); } else { - console.log('\nAborted!'); // TODO: maybe only show 'Aborted!' if user cancels? + console.log('\nAborted!'); } }, {minimumWait: 2000}); diff --git a/source/npm/util.js b/source/npm/util.js index 10d74dd4..792e1379 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -52,7 +52,7 @@ export const collaborators = async pkg => { ow(packageName, ow.string); const npmVersion = await version(); - // TODO: remove old command when targeting Node.js 18 + // 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]; diff --git a/source/util.js b/source/util.js index 88b2ded5..84390f2c 100644 --- a/source/util.js +++ b/source/util.js @@ -59,13 +59,10 @@ export const getTagVersionPrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { - // TODO: test with 'options.yarn' - 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'; @@ -107,26 +104,14 @@ export const getNewDependencies = async (newPkg, rootDir) => { return newDependencies; }; -// TODO: test 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 packageManager = options.yarn ? 'yarn' : 'npm'; + const {stdout} = await execa(packageManager, ['config', 'get', 'preId']); - const {stdout} = await execa('npm', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } - - return ''; + return stdout === 'undefined' ? '' : stdout; } catch { return ''; } @@ -135,6 +120,6 @@ export const getPreReleasePrefix = pMemoize(async options => { export const validateEngineVersionSatisfies = (engine, version) => { const engineRange = npPkg.engines[engine]; if (!new Version(version).satisfies(engineRange)) { - throw new Error(`\`np\` requires ${engine} ${engineRange}`); // TODO: prettify range/engine, capitalize engine + throw new Error(`\`np\` requires ${engine} ${engineRange}`); } }; diff --git a/source/version.js b/source/version.js index 5aa0b49f..99e0dd46 100644 --- a/source/version.js +++ b/source/version.js @@ -48,8 +48,6 @@ export default class Version { this.#version = semver.parse(version); if (this.#version === null) { - // TODO: maybe make a custom InvalidSemVerError? - // TODO: linkify '`SemVer` version' throw new Error(`Version \`${version}\` should be a valid \`SemVer\` version.`); } } @@ -109,7 +107,7 @@ export default class Version { @param {string | SemVerInstance} [options.previousVersion] @returns {string} A color-formatted version string. */ - format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { // TODO: `ColorName` type could be better to allow e.g. bgRed.blue + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { if (typeof previousVersion === 'string') { const previousSemver = semver.parse(previousVersion); @@ -146,7 +144,7 @@ export default class Version { 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('.')}}}`) : '' // TODO: throw error if somehow invalid???? + this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' ); /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ } diff --git a/test/cli.js b/test/cli.js index a37bc1eb..beb7b72e 100644 --- a/test/cli.js +++ b/test/cli.js @@ -5,7 +5,6 @@ import {cliPasses} from './_helpers/verify-cli.js'; const cli = path.resolve(rootDir, 'source', 'cli-implementation.js'); -// TODO: update help text in readme test('flags: --help', cliPasses, cli, '--help', [ '', 'A better `npm publish`', diff --git a/test/npm/handle-npm-error.js b/test/npm/handle-npm-error.js index 42dd70c7..61b6959a 100644 --- a/test/npm/handle-npm-error.js +++ b/test/npm/handle-npm-error.js @@ -18,5 +18,3 @@ test('error code 402 - privately publish scoped package', t => { {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, ); }); - -// TODO: OTP test? diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 45b80676..b0ed2dd3 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -112,8 +112,6 @@ test.serial('should fail when user is not authenticated at npm registry', create assertTaskFailed(t, 'Verify user is authenticated'); }); -// TODO: 'Verify user is authenticated' - verify passes if no collaborators - test.serial('should fail when user is not authenticated at external registry', createFixture, [ { command: 'npm whoami --registry http://my.io', @@ -140,6 +138,8 @@ test.serial('should fail when user is not authenticated at external registry', c assertTaskFailed(t, 'Verify user is authenticated'); }); +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: '', diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js index 143c4d03..862a26ef 100644 --- a/test/util/get-new-dependencies.js +++ b/test/util/get-new-dependencies.js @@ -8,7 +8,7 @@ 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 -A`; + await $$`git add .`; await $$`git commit -m "added"`; await $$`git tag v0.0.0`; await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js new file mode 100644 index 00000000..69422e88 --- /dev/null +++ b/test/util/get-pre-release-prefix.js @@ -0,0 +1,62 @@ +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"]`'}, + ); +}); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js new file mode 100644 index 00000000..57f2e6d6 --- /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 preId', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: false}), + 'ver', + ); +}); + +test('returns preId postfix - yarn', createFixture, [{ + command: 'yarn config get preId', + 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 preId', + 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/prefix.js b/test/util/prefix.js deleted file mode 100644 index e7677456..00000000 --- a/test/util/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('defaults to "v" when command fails', async t => { - const util = await esmock('../../source/util.js', { - execa: {execa: Promise.reject}, - }); - - t.is(await util.getTagVersionPrefix({yarn: true}), 'v'); -}); diff --git a/test/util/preid.js b/test/util/preid.js deleted file mode 100644 index 986b31ce..00000000 --- a/test/util/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/version.js b/test/version.js index d6dc835f..a05f1862 100644 --- a/test/version.js +++ b/test/version.js @@ -136,12 +136,12 @@ test('format - prerelease as prepatch', t => { }); test('format - prerelease with multiple numbers', t => { - const newVersion = makeNewFormattedVersion('0.0.{1}-{0.0}'); // TODO: should it be {0}.{0}? + 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}'); // TODO: should it be {alpha}.{0}? + const newVersion = makeNewFormattedVersion('0.0.{1}-{alpha.0}'); t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); }); From 1cdf40b6ce8592768fb8aa2e2768b5e47e0744ec Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 12:54:32 -0500 Subject: [PATCH 49/63] fix lint --- test/_helpers/mock-inquirer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 7cf4c85d..ac263218 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -1,4 +1,3 @@ -import {debuglog} from 'node:util'; import esmock from 'esmock'; import is from '@sindresorhus/is'; import stripAnsi from 'strip-ansi'; From 57c9df4c006b4d36e1133c74b638179c9ab58362 Mon Sep 17 00:00:00 2001 From: Tommy Date: Mon, 31 Jul 2023 13:01:27 -0500 Subject: [PATCH 50/63] fix: correct config name --- test/util/get-tag-version-prefix.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index 57f2e6d6..aa387110 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -7,7 +7,7 @@ import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/u const createFixture = _createFixture('../../source/util.js', import.meta.url); test('returns tag prefix - npm', createFixture, [{ - command: 'npm config get preId', + command: 'npm config get tag-version-prefix', stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -17,7 +17,7 @@ test('returns tag prefix - npm', createFixture, [{ }); test('returns preId postfix - yarn', createFixture, [{ - command: 'yarn config get preId', + command: 'yarn config get version-tag-prefix', stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -27,7 +27,7 @@ test('returns preId postfix - yarn', createFixture, [{ }); test('defaults to "v" when command fails', createFixture, [{ - command: 'npm config get preId', + command: 'npm config get tag-version-prefix', exitCode: 1, }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( From 8d5ede540239f3b4f139245ffc77ced1bd645693 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 13:54:23 -0500 Subject: [PATCH 51/63] document test helpers --- test/_helpers/integration-test.js | 10 ++++++---- test/_helpers/mock-inquirer.js | 33 ++++++++++++++++++++----------- test/_helpers/stub-execa.js | 10 +++++++++- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index 85cb7751..1e051589 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -8,23 +8,25 @@ 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 "init1"`; + 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}); - await createEmptyGitRepo($$, temporaryDir); - - t.context.firstCommitMessage = '"init1"'; // From createEmptyGitRepo + t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDir); // From https://stackoverflow.com/a/3357357/10292952 t.context.getCommitMessage = async sha => { diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index ac263218..9a4c1d92 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -3,9 +3,6 @@ import is from '@sindresorhus/is'; import stripAnsi from 'strip-ansi'; import mapObject from 'map-obj'; -// NOTE: This only handles prompts of type 'input', 'list', and 'confirm'. If other prompt types are added, they must be implemented here. -// Based on https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 - /** @typedef {import('ava').ExecutionContext>} ExecutionContext */ /** @typedef {string | boolean} ShortAnswer */ /** @typedef {Record<'input' | 'error', string> | Record<'choice', string> | Record<'confirm', boolean>} LongAnswer */ @@ -14,7 +11,15 @@ import mapObject from 'map-obj'; /** @typedef {import('inquirer').DistinctQuestion & {name?: never}} Prompt */ /** -@param {object} o +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 @@ -175,17 +180,24 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { return answers; }; -/** @param {import('esmock').MockMap} mocks */ +/** +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]); /** -@param {object} o +Mocks `inquirer` for testing `source/ui.js`. + +@param {object} o Test input and optional global mocks @param {ExecutionContext} o.t -@param {Answers} o.answers -@param {import('esmock').MockMap} [o.mocks] -@param {string[]} [o.logs] +@param {Answers} o.answers Test input +@param {import('esmock').MockMap} [o.mocks] Optional global mocks */ -export const mockInquirer = async ({t, answers, mocks = {}, logs = []}) => { +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: { @@ -202,7 +214,6 @@ export const mockInquirer = async ({t, answers, mocks = {}, logs = []}) => { }, }, { ...fixRelativeMocks(mocks), - // Mock globals import: { console: {log: (...args) => logs.push(...args)}, }, diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index c613b218..f5c0973e 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -4,13 +4,21 @@ 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(' '); - // 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) { From b286a28a29bddad45210b4511bee87db3ee9cff5 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 14:22:05 -0500 Subject: [PATCH 52/63] remove todos --- source/version.js | 2 -- test/ui/new-files-dependencies.js | 1 - 2 files changed, 3 deletions(-) diff --git a/source/version.js b/source/version.js index 99e0dd46..c74e0cff 100644 --- a/source/version.js +++ b/source/version.js @@ -118,13 +118,11 @@ export default class Version { previousVersion = previousSemver; } - // TODO: should previousVersion take precendence over this.#diff? if (!this.#diff) { if (!previousVersion) { return chalk(`{${color} ${this.toString()}}`); } - // TODO: maybe allow passing a Version instance too? this.#diff = semver.diff(previousVersion, this.#version); } diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 5d3c1c80..dc328535 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -41,7 +41,6 @@ const createFixture = test.macro(async (t, pkg, commands, expected) => { await commands({t, $$, temporaryDir}); pkg = await readPackage({cwd: temporaryDir}); - // TODO: describe mocks const {ui, logs: logsArray} = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { './npm/util.js': { getRegistryUrl: sinon.stub().resolves(''), From 4aa52f8c0cb6a053b2abfd1a34a5bcd5fafd6c95 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Mon, 31 Jul 2023 14:22:12 -0500 Subject: [PATCH 53/63] fix: move `chalk-template` to regular deps --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43ec2321..33eb507a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ ], "dependencies": { "chalk": "^5.3.0", + "chalk-template": "^1.1.0", "cosmiconfig": "^8.1.3", "del": "^7.0.0", "escape-goat": "^4.0.0", @@ -73,7 +74,6 @@ "@sindresorhus/is": "^5.6.0", "@types/semver": "^7.5.0", "ava": "^5.3.1", - "chalk-template": "^1.1.0", "common-tags": "^1.8.2", "esmock": "^2.3.4", "fs-extra": "^11.1.1", From f140762a059b819377ba420d819008eb3a0e335a Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 1 Aug 2023 21:00:14 -0500 Subject: [PATCH 54/63] try resolving `util.readPkg` error --- source/index.js | 2 +- source/util.js | 7 +++++-- test/util/read-pkg.js | 19 +++++++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/source/index.js b/source/index.js index f11e5a6b..03e705b9 100644 --- a/source/index.js +++ b/source/index.js @@ -64,7 +64,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(); } diff --git a/source/util.js b/source/util.js index 84390f2c..34c707e8 100644 --- a/source/util.js +++ b/source/util.js @@ -1,3 +1,4 @@ +import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {readPackageUp} from 'read-pkg-up'; import {parsePackage} from 'read-pkg'; @@ -11,7 +12,9 @@ import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -export const readPkg = async packagePath => { +export const npRootDir = fileURLToPath(new URL('..', import.meta.url)); + +export const readPkg = async (packagePath = npRootDir) => { const packageResult = await readPackageUp({cwd: packagePath}); if (!packageResult) { @@ -21,7 +24,7 @@ export const readPkg = async packagePath => { return {pkg: packageResult.packageJson, rootDir: path.dirname(packageResult.path)}; }; -export const {pkg: npPkg, rootDir: npRootDir} = await readPkg(); +export const {pkg: npPkg} = await readPkg(npRootDir); export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js index 9fd7132c..3c7838e8 100644 --- a/test/util/read-pkg.js +++ b/test/util/read-pkg.js @@ -1,17 +1,17 @@ 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 __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '../..'); +const rootDir = fileURLToPath(new URL('../..', import.meta.url)); -test('without packagePath', async t => { +test('without packagePath returns np package.json', async t => { const {pkg, rootDir: pkgDir} = await readPkg(); t.is(pkg.name, 'np'); - t.is(pkgDir, rootDir); + t.is(pkgDir + '/', rootDir); }); test('with packagePath', async t => { @@ -36,3 +36,14 @@ test('npPkg', t => { 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); +}); From b06fc981728458cd6cb06ec713cfdf58123fa4e5 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Tue, 1 Aug 2023 21:15:40 -0500 Subject: [PATCH 55/63] better resolution of issue --- source/util.js | 7 ++++--- test/util/read-pkg.js | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/source/util.js b/source/util.js index 34c707e8..c03b629c 100644 --- a/source/util.js +++ b/source/util.js @@ -12,9 +12,9 @@ import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -export const npRootDir = fileURLToPath(new URL('..', import.meta.url)); +const _npRootDir = fileURLToPath(new URL('..', import.meta.url)); -export const readPkg = async (packagePath = npRootDir) => { +export const readPkg = async (packagePath = _npRootDir) => { const packageResult = await readPackageUp({cwd: packagePath}); if (!packageResult) { @@ -24,7 +24,8 @@ export const readPkg = async (packagePath = npRootDir) => { return {pkg: packageResult.packageJson, rootDir: path.dirname(packageResult.path)}; }; -export const {pkg: npPkg} = await readPkg(npRootDir); +// 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)) { diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js index 3c7838e8..e9dd49c8 100644 --- a/test/util/read-pkg.js +++ b/test/util/read-pkg.js @@ -5,13 +5,13 @@ import esmock from 'esmock'; import {temporaryDirectory} from 'tempy'; import {readPkg, npPkg, npRootDir} from '../../source/util.js'; -const rootDir = fileURLToPath(new URL('../..', import.meta.url)); +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); + t.is(pkgDir, rootDir); }); test('with packagePath', async t => { From 6ba3b4b5aa9940733ac021b2fc6e0edd5c92ecda Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 31 Aug 2023 11:05:13 -0500 Subject: [PATCH 56/63] fixes * handle `preid` correctly and include in version formatting * default to `process.cwd()` in `util.readPkg` * fix prerelease handling in `release-task-helper` and add tests --- source/index.js | 3 +- source/release-task-helper.js | 12 ++---- source/ui.js | 2 +- source/util.js | 9 +++-- source/version.js | 17 ++++++-- test/release-task-helper.js | 61 +++++++++++++++++++++++++++++ test/util/get-pre-release-prefix.js | 22 ++++++++--- test/version.js | 5 +++ 8 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 test/release-task-helper.js diff --git a/source/index.js b/source/index.js index 03e705b9..13a3526c 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'; @@ -281,7 +280,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/release-task-helper.js b/source/release-task-helper.js index 5fde61d6..28c5915a 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,19 +4,15 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { - const newVersion = new Version(pkg.version).setFrom(options.version); - let tag = await getTagVersionPrefix(options) + newVersion; - - const isPrerelease = new Version(options.version).isPrerelease(); - if (isPrerelease) { - tag += await getPreReleasePrefix(options); - } + const prereleasePrefix = await getPreReleasePrefix(options); + const newVersion = new Version(pkg.version).setFrom(options.version, {prereleasePrefix}); + const tag = await getTagVersionPrefix(options) + newVersion.toString(); const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, body: options.releaseNotes(tag), - isPrerelease, + isPrerelease: newVersion.isPrerelease(), }); await open(url); diff --git a/source/ui.js b/source/ui.js index 637c4759..9c1ef20a 100644 --- a/source/ui.js +++ b/source/ui.js @@ -144,7 +144,7 @@ const ui = async (options, {pkg, rootDir}) => { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); } else { const versionText = options.version - ? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion, options.version).format()})`) + ? 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`); diff --git a/source/util.js b/source/util.js index c03b629c..8d31e6ec 100644 --- a/source/util.js +++ b/source/util.js @@ -1,3 +1,4 @@ +import process from 'node:process'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {readPackageUp} from 'read-pkg-up'; @@ -12,9 +13,7 @@ import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -const _npRootDir = fileURLToPath(new URL('..', import.meta.url)); - -export const readPkg = async (packagePath = _npRootDir) => { +export const readPkg = async (packagePath = process.cwd()) => { const packageResult = await readPackageUp({cwd: packagePath}); if (!packageResult) { @@ -24,6 +23,8 @@ export const readPkg = async (packagePath = _npRootDir) => { 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); @@ -113,7 +114,7 @@ export const getPreReleasePrefix = pMemoize(async options => { try { const packageManager = options.yarn ? 'yarn' : 'npm'; - const {stdout} = await execa(packageManager, ['config', 'get', 'preId']); + const {stdout} = await execa(packageManager, ['config', 'get', 'preid']); return stdout === 'undefined' ? '' : stdout; } catch { diff --git a/source/version.js b/source/version.js index c74e0cff..516ef454 100644 --- a/source/version.js +++ b/source/version.js @@ -34,6 +34,9 @@ export default class Version { /** @type {SemVerIncrement | undefined} */ #diff = undefined; + /** @type {string | undefined} */ + #prereleasePrefix = undefined; + toString() { return this.#version.version; } @@ -55,8 +58,11 @@ export default class Version { /** @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) { + constructor(version, increment, {prereleasePrefix} = {}) { + this.#prereleasePrefix = prereleasePrefix; this.#trySetVersion(version); if (increment) { @@ -72,13 +78,16 @@ export default class 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) { + setFrom(input, {prereleasePrefix = ''} = {}) { + this.#prereleasePrefix ??= prereleasePrefix; const previousVersion = this.toString(); if (isSemVerIncrement(input)) { - this.#version.inc(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.`); @@ -104,7 +113,7 @@ export default class Version { @param {object} options @param {ColorName} [options.color = 'dim'] @param {ColorName} [options.diffColor = 'cyan'] - @param {string | SemVerInstance} [options.previousVersion] + @param {string} [options.prereleasePrefix] @returns {string} A color-formatted version string. */ format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { 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/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js index 69422e88..113294c7 100644 --- a/test/util/get-pre-release-prefix.js +++ b/test/util/get-pre-release-prefix.js @@ -1,3 +1,4 @@ +import process from 'node:process'; import test from 'ava'; import {stripIndent} from 'common-tags'; import {_createFixture} from '../_helpers/stub-execa.js'; @@ -6,8 +7,8 @@ import {getPreReleasePrefix as originalGetPreReleasePrefix} from '../../source/u /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/util.js', import.meta.url); -test('returns preId postfix if set - npm', createFixture, [{ - command: 'npm config get preId', +test('returns preid postfix if set - npm', createFixture, [{ + command: 'npm config get preid', stdout: 'pre', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( @@ -16,8 +17,8 @@ test('returns preId postfix if set - npm', createFixture, [{ ); }); -test('returns preId postfix if set - yarn', createFixture, [{ - command: 'yarn config get preId', +test('returns preid postfix if set - yarn', createFixture, [{ + command: 'yarn config get preid', stdout: 'pre', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( @@ -27,7 +28,7 @@ test('returns preId postfix if set - yarn', createFixture, [{ }); test('returns empty string if not set - npm', createFixture, [{ - command: 'npm config get preId', + command: 'npm config get preid', stdout: 'undefined', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( @@ -37,7 +38,7 @@ test('returns empty string if not set - npm', createFixture, [{ }); test('returns empty string if not set - yarn', createFixture, [{ - command: 'yarn config get preId', + command: 'yarn config get preid', stdout: 'undefined', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( @@ -60,3 +61,12 @@ test('no options passed', async t => { {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/version.js b/test/version.js index a05f1862..bf7c9435 100644 --- a/test/version.js +++ b/test/version.js @@ -227,3 +227,8 @@ test('isPrerelease', t => { 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'); +}); From cd16b94436faf5cfb7e8b5977ad401a47c564259 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 31 Aug 2023 11:13:04 -0500 Subject: [PATCH 57/63] update deps --- package.json | 26 +++++++++++++------------- source/index.js | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 33eb507a..9de8bdfb 100644 --- a/package.json +++ b/package.json @@ -34,17 +34,17 @@ "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.7", + "inquirer": "^9.2.10", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -52,7 +52,7 @@ "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", @@ -62,27 +62,27 @@ "p-timeout": "^6.1.2", "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", - "read-pkg": "^8.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.3", + "semver": "^7.5.4", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" }, "devDependencies": { - "@sindresorhus/is": "^5.6.0", - "@types/semver": "^7.5.0", + "@sindresorhus/is": "^6.0.0", + "@types/semver": "^7.5.1", "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.3.4", + "esmock": "^2.3.8", "fs-extra": "^11.1.1", "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.54.2" + "xo": "^0.56.0" }, "ava": { "files": [ diff --git a/source/index.js b/source/index.js index 13a3526c..892eb0c9 100644 --- a/source/index.js +++ b/source/index.js @@ -84,7 +84,7 @@ 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); From c92873cf1d5ba1b7f1a6adbab58ea9b09a509d4d Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 31 Aug 2023 11:17:36 -0500 Subject: [PATCH 58/63] fix: lint --- source/index.js | 4 ++-- test/fixtures/files/npmignore-and-gitignore/script/build.js | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/source/index.js b/source/index.js index 892eb0c9..a5c1fe46 100644 --- a/source/index.js +++ b/source/index.js @@ -160,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}.`; @@ -184,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}.`; 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 From 361b60935974ed9e7b29af5833244a4988e73d53 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 31 Aug 2023 11:21:59 -0500 Subject: [PATCH 59/63] fix: missing option in test --- test/ui/new-files-dependencies.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index dc328535..97a46172 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -51,7 +51,7 @@ const createFixture = test.macro(async (t, pkg, commands, expected) => { 'is-interactive': () => false, }}); - await ui({runPublish: true, version: 'major'}, {pkg, rootDir: temporaryDir}); + 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; From e3c5e5c0cf77de2b878317996530895f3928c071 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Thu, 31 Aug 2023 17:03:41 -0500 Subject: [PATCH 60/63] fix: ignore some checks when `--release-draft-only` --- source/cli-implementation.js | 3 ++- source/index.js | 1 + source/release-task-helper.js | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index d9db4b2e..e706f2ad 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -120,7 +120,8 @@ 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, }; diff --git a/source/index.js b/source/index.js index a5c1fe46..c4aa6c30 100644 --- a/source/index.js +++ b/source/index.js @@ -263,6 +263,7 @@ 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), }] : [], ], { diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 28c5915a..51f62058 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,8 +4,10 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { - const prereleasePrefix = await getPreReleasePrefix(options); - const newVersion = new Version(pkg.version).setFrom(options.version, {prereleasePrefix}); + 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({ From f98b85d079200ce0adab22f94231fb26bd36a9e0 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 1 Sep 2023 15:15:09 -0500 Subject: [PATCH 61/63] fix(`prerequisite-tasks`): don't double parse version --- source/prerequisite-tasks.js | 4 +++- source/ui.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index b207c3c3..4f9fc597 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -59,7 +59,9 @@ const prerequisiteTasks = (input, pkg, options) => { { title: 'Validate version', task() { - newVersion = new Version(pkg.version).setFrom(input); + newVersion = input instanceof Version + ? input + : new Version(pkg.version).setFrom(input); }, }, { diff --git a/source/ui.js b/source/ui.js index 9c1ef20a..fe354576 100644 --- a/source/ui.js +++ b/source/ui.js @@ -224,7 +224,7 @@ const ui = async (options, {pkg, rootDir}) => { message: 'Select SemVer increment or specify new version', pageSize: SEMVER_INCREMENTS.length + 2, choices: [ - ...SEMVER_INCREMENTS.map(inc => ({ + ...SEMVER_INCREMENTS.map(inc => ({ // TODO: prerelease prefix here too name: `${inc} ${new Version(oldVersion, inc).format()}`, value: inc, })), From c6249a2c07d09358a1eb529e4fef2972856027c1 Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 1 Sep 2023 17:11:40 -0500 Subject: [PATCH 62/63] version style tweaks --- source/ui.js | 4 ++-- source/version.js | 16 ++++++++-------- test/version.js | 20 ++++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/source/ui.js b/source/ui.js index fe354576..37a39def 100644 --- a/source/ui.js +++ b/source/ui.js @@ -242,7 +242,7 @@ const ui = async (options, {pkg, rootDir}) => { when: answers => answers.version === undefined, filter(input) { if (SEMVER_INCREMENTS.includes(input)) { - throw new Error('Custom version should not be a `SemVer` increment.'); + throw new Error('Custom version should not be a SemVer increment.'); } const version = new Version(oldVersion); @@ -252,7 +252,7 @@ const ui = async (options, {pkg, rootDir}) => { version.setFrom(input); } catch (error) { if (error.message.includes('valid `SemVer` version')) { - throw new Error(`Custom version \`${input}\` should be a valid \`SemVer\` version.`); + throw new Error(`Custom version ${input} should be a valid SemVer version.`); } error.message = error.message.replace('New', 'Custom'); diff --git a/source/version.js b/source/version.js index 516ef454..46840d05 100644 --- a/source/version.js +++ b/source/version.js @@ -3,8 +3,8 @@ import {template as chalk} from 'chalk-template'; /** @type {string[]} Allowed `SemVer` release types. */ export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); -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)}\``; +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 */ @@ -51,7 +51,7 @@ export default class Version { this.#version = semver.parse(version); if (this.#version === null) { - throw new Error(`Version \`${version}\` should be a valid \`SemVer\` version.`); + throw new Error(`Version ${version} should be a valid SemVer version.`); } } @@ -67,7 +67,7 @@ export default class Version { if (increment) { if (!isSemVerIncrement(increment)) { - throw new Error(`Increment \`${increment}\` should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); + throw new Error(`Increment ${increment} should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); } this.setFrom(increment); @@ -90,11 +90,11 @@ export default class Version { 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.`); + 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()}\`.`); + throw new Error(`New version ${input} should be higher than current version ${this.toString()}.`); } this.#trySetVersion(input); @@ -121,7 +121,7 @@ export default class Version { const previousSemver = semver.parse(previousVersion); if (previousSemver === null) { - throw new Error(`Previous version \`${previousVersion}\` should be a valid \`SemVer\` version.`); + throw new Error(`Previous version ${previousVersion} should be a valid SemVer version.`); } previousVersion = previousSemver; @@ -164,7 +164,7 @@ export default class Version { */ satisfies(range) { if (!semver.validRange(range)) { - throw new Error(`Range \`${range}\` is not a valid \`SemVer\` range.`); + throw new Error(`Range ${range} is not a valid SemVer range.`); } return semver.satisfies(this.#version, range, { diff --git a/test/version.js b/test/version.js index bf7c9435..a22d8626 100644 --- a/test/version.js +++ b/test/version.js @@ -4,8 +4,8 @@ import {template as chalk} from 'chalk-template'; import semver from 'semver'; import Version from '../source/version.js'; -const INCREMENT_LIST = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`'; -const INCREMENT_LIST_OR = '`major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, or `prerelease`'; +const INCREMENT_LIST = 'major, minor, patch, premajor, preminor, prepatch, prerelease'; +const INCREMENT_LIST_OR = 'major, minor, patch, premajor, preminor, prepatch, or prerelease'; /** @param {string} input - Place `{ }` around the version parts to be highlighted. */ const makeNewFormattedVersion = input => { @@ -20,7 +20,7 @@ test('new Version - valid', t => { test('new Version - invalid', t => { t.throws( () => new Version('major'), - {message: 'Version `major` should be a valid `SemVer` version.'}, + {message: 'Version major should be a valid SemVer version.'}, ); }); @@ -31,21 +31,21 @@ test('new Version - valid w/ valid increment', t => { test('new Version - invalid w/ valid increment', t => { t.throws( () => new Version('major', 'major'), - {message: 'Version `major` should be a valid `SemVer` version.'}, + {message: 'Version major should be a valid SemVer version.'}, ); }); 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}.`}, + {message: `Increment 2.0.0 should be one of ${INCREMENT_LIST_OR}.`}, ); }); 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.'}, + {message: 'Version major should be a valid SemVer version.'}, ); }); @@ -57,14 +57,14 @@ test('setFrom - valid input as version', t => { 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.`}, + {message: `New version 200 should either be one of ${INCREMENT_LIST}, 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`.'}, + {message: 'New version 0.2.0 should be higher than current version 1.0.0.'}, ); }); @@ -199,7 +199,7 @@ test('format - previousVersion as SemVer instance', t => { 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.'}, + {message: 'Previous version 000 should be a valid SemVer version.'}, ); }); @@ -214,7 +214,7 @@ test('satisfies', t => { t.throws( () => new Version('1.2.3').satisfies('=>1.0.0'), - {message: 'Range `=>1.0.0` is not a valid `SemVer` range.'}, + {message: 'Range =>1.0.0 is not a valid SemVer range.'}, ); }); From a682b514b2c7d3b35934a3f21db07a9ca64fde8a Mon Sep 17 00:00:00 2001 From: tommy-mitchell Date: Fri, 1 Sep 2023 17:45:32 -0500 Subject: [PATCH 63/63] semver order --- source/ui.js | 2 +- source/version.js | 2 +- test/cli.js | 2 +- test/index.js | 4 ++-- test/tasks/prerequisite-tasks.js | 4 ++-- test/ui/prompts/version.js | 8 ++++---- test/version.js | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/source/ui.js b/source/ui.js index 37a39def..29f1c980 100644 --- a/source/ui.js +++ b/source/ui.js @@ -251,7 +251,7 @@ const ui = async (options, {pkg, rootDir}) => { // Version error handling does validation version.setFrom(input); } catch (error) { - if (error.message.includes('valid `SemVer` version')) { + if (error.message.includes('valid SemVer version')) { throw new Error(`Custom version ${input} should be a valid SemVer version.`); } diff --git a/source/version.js b/source/version.js index 46840d05..82b21ce4 100644 --- a/source/version.js +++ b/source/version.js @@ -2,7 +2,7 @@ import semver from 'semver'; import {template as chalk} from 'chalk-template'; /** @type {string[]} Allowed `SemVer` release types. */ -export const SEMVER_INCREMENTS = semver.RELEASE_TYPES.sort(); +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)}`; diff --git a/test/cli.js b/test/cli.js index beb7b72e..c898fd67 100644 --- a/test/cli.js +++ b/test/cli.js @@ -13,7 +13,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '$ np ', '', 'Version can be:', - 'major | minor | patch | premajor | preminor | prepatch | prerelease | 1.2.3', + 'patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3', '', 'Options', '--any-branch Allow publishing from any branch', diff --git a/test/index.js b/test/index.js index 5f622fb8..bf3ac759 100644 --- a/test/index.js +++ b/test/index.js @@ -27,7 +27,7 @@ const npFails = test.macro(async (t, inputs, message) => { test('version is invalid', npFails, ['foo', '4.x.3'], - /New version `(?:foo|4\.x\.3)` should either be one of `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `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,7 +37,7 @@ 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 => { diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index b0ed2dd3..a2f8b304 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -186,7 +186,7 @@ test.serial('should fail when git remote does not exist', createFixture, [{ 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: 'New version `DDD` should either be one of `major`, `minor`, `patch`, `premajor`, `preminor`, `prepatch`, `prerelease`, 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'); @@ -195,7 +195,7 @@ test.serial('should fail when version is invalid', 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'); diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 5e7267ca..eb1021fb 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -104,19 +104,19 @@ test('choose custom - validation', testUi, { customVersion: [ { input: 'major', - error: 'Custom version should not be a `SemVer` increment.', + error: 'Custom version should not be a SemVer increment.', }, { input: '200', - error: 'Custom version `200` should be a valid `SemVer` version.', + 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`.', + 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`.', + error: 'Custom version 1.0.0 should be higher than current version 1.0.0.', }, { input: '2.0.0', diff --git a/test/version.js b/test/version.js index a22d8626..30e203f5 100644 --- a/test/version.js +++ b/test/version.js @@ -4,8 +4,8 @@ import {template as chalk} from 'chalk-template'; import semver from 'semver'; import Version from '../source/version.js'; -const INCREMENT_LIST = 'major, minor, patch, premajor, preminor, prepatch, prerelease'; -const INCREMENT_LIST_OR = 'major, minor, patch, premajor, preminor, prepatch, or 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 => {