diff --git a/.github/funding.yml b/.github/funding.yml deleted file mode 100644 index 0038dab5..00000000 --- a/.github/funding.yml +++ /dev/null @@ -1 +0,0 @@ -github: sindresorhus diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..faeaa6c8 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,23 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 24 + - 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - run: git config --global user.name "Github Actions" + - run: git config --global user.email "actions@users.noreply.github.com" + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bec5fdd6..00000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js -node_js: - - '12' - - '10' -before_script: - - npm install --global npm@6.8.0 diff --git a/license b/license index e7af2f77..fa7ceba3 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/private-packages.png b/media/private-packages.png similarity index 100% rename from private-packages.png rename to media/private-packages.png diff --git a/screenshot-ui.png b/media/screenshot-ui.png similarity index 100% rename from screenshot-ui.png rename to media/screenshot-ui.png diff --git a/screenshot.gif b/media/screenshot.gif similarity index 100% rename from screenshot.gif rename to media/screenshot.gif diff --git a/package.json b/package.json index f2ca454d..bececf4e 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,22 @@ { "name": "np", - "version": "6.5.0", + "version": "11.2.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", "funding": "https://github.com/sindresorhus/np?sponsor=1", - "bin": "source/cli.js", + "type": "module", + "bin": "./source/cli.js", "engines": { - "node": ">=10", - "npm": ">=6.8.0", + "node": ">=20", + "npm": ">=9", "git": ">=2.11.0", - "yarn": ">=1.7.0" + "yarn": ">=1.7.0", + "pnpm": ">=8", + "bun": ">=1" }, "scripts": { - "test": "xo && FORCE_HYPERLINK=1 ava" + "test": "xo && ava" }, "files": [ "source" @@ -30,53 +33,79 @@ "commit" ], "dependencies": { - "@samverschueren/stream-to-observable": "^0.3.0", - "any-observable": "^0.5.0", - "async-exit-hook": "^2.0.1", - "chalk": "^3.0.0", - "cosmiconfig": "^6.0.0", - "del": "^4.1.0", - "escape-goat": "^3.0.0", - "escape-string-regexp": "^2.0.0", - "execa": "^4.0.0", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "clipboardy": "^5.3.1", + "cosmiconfig": "^9.0.1", + "del": "^8.0.1", + "escape-goat": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "execa": "^9.6.1", + "exit-hook": "^5.1.0", "github-url-from-git": "^1.5.0", - "has-yarn": "^2.1.0", - "hosted-git-info": "^3.0.0", - "inquirer": "^7.0.0", - "is-installed-globally": "^0.3.1", - "is-scoped": "^2.1.0", - "issue-regex": "^3.1.0", + "hosted-git-info": "^9.0.2", + "ignore-walk": "^8.0.0", + "import-local": "^3.2.0", + "inquirer": "^13.4.1", + "is-installed-globally": "^1.0.0", + "is-interactive": "^2.0.0", + "is-scoped": "^3.0.0", + "issue-regex": "^4.3.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^3.0.0", - "meow": "^6.0.0", - "new-github-release-url": "^1.0.0", - "npm-name": "^6.0.0", - "onetime": "^5.1.0", - "open": "^7.0.0", - "ow": "^0.15.0", - "p-memoize": "^3.1.0", - "p-timeout": "^3.1.0", - "pkg-dir": "^4.1.0", - "read-pkg-up": "^7.0.0", - "rxjs": "^6.5.4", - "semver": "^7.1.1", - "split": "^1.0.0", - "symbol-observable": "^1.2.0", - "terminal-link": "^2.0.0", - "update-notifier": "^4.0.0" + "log-symbols": "^7.0.1", + "meow": "^14.1.0", + "new-github-release-url": "^2.0.0", + "npm-name": "^8.1.0", + "onetime": "^7.0.0", + "open": "^11.0.0", + "p-memoize": "^8.0.0", + "package-directory": "^8.2.0", + "path-exists": "^5.0.0", + "read-package-up": "^12.0.0", + "read-pkg": "^10.1.0", + "rxjs": "^7.8.2", + "semver": "^7.7.4", + "symbol-observable": "^4.0.0", + "terminal-link": "^5.0.0", + "update-notifier": "^7.3.1" }, "devDependencies": { - "ava": "^2.3.0", - "execa_test_double": "^4.0.0", - "mockery": "^2.1.0", - "proxyquire": "^2.1.0", - "sinon": "^8.0.1", - "xo": "^0.25.3" + "@sindresorhus/is": "^7.2.0", + "@types/semver": "^7.7.1", + "ava": "^6.4.1", + "common-tags": "^1.8.2", + "esmock": "^2.7.3", + "fs-extra": "^11.3.4", + "map-obj": "^6.0.0", + "sinon": "^21.1.2", + "strip-ansi": "^7.2.0", + "tempy": "^3.2.0", + "write-package": "^7.2.0", + "xo": "^1.2.3" }, "ava": { "files": [ - "!test/fixtures" + "!test/fixtures", + "!test/_helpers" + ], + "environmentVariables": { + "FORCE_HYPERLINK": "1", + "HOME": "/tmp", + "NODE_ENV": "test", + "GIT_AUTHOR_NAME": "test", + "GIT_COMMITTER_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_EMAIL": "test@example.com" + }, + "nodeArguments": [ + "--loader=esmock", + "--no-warnings=ExperimentalWarning" + ] + }, + "xo": { + "ignores": [ + "test/fixtures" ] } } diff --git a/readme.md b/readme.md index 2f4fc658..3c0a7a74 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,8 @@ -# np [![Build Status](https://travis-ci.com/sindresorhus/np.svg?branch=master)](https://travis-ci.com/github/sindresorhus/np) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) +# np [![XO code style](https://shields.io/badge/code_style-5ed9c7?logo=xo&labelColor=gray&logoSize=auto&logoWidth=20)](https://github.com/xojs/xo) > A better `npm publish` - + ## Why @@ -23,19 +23,25 @@ (does not apply to external registries) - Opens a prefilled GitHub Releases draft after publish - Warns about the possibility of extraneous files being published -- See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely +- See exactly what will be executed with [dry-run mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) +- Supports npm 9+, Yarn (Classic and Berry), pnpm 8+, and Bun + +### Why not + +- Monorepos are not supported. +- CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. ## Prerequisite -- Node.js 10 or later -- npm 6.8.0 or later +- Node.js 20 or later +- npm 9 or later - Git 2.11 or later ## Install -``` -$ npm install --global np +```sh +npm install --global np ``` ## Usage @@ -50,19 +56,24 @@ $ np --help patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3 Options - --any-branch Allow publishing from any branch - --branch Name of the release branch (default: 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 - --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) + --any-branch Allow publishing from any branch + --branch Name of the release branch (default: main | master) + --no-cleanup Skips np's node_modules cleanup step before install + --no-tests Skips tests + --yolo Skips cleanup and testing + --no-publish Skips publishing + --dry-run Show tasks without actually executing them + --tag Publish under a given dist-tag + --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 + --no-release-notes Skips generating release notes when opening a GitHub release draft + --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) + --package-manager Use a specific package manager (default: package.json packageManager/devEngines) + --provenance Publish with npm provenance statements (CI-only) + --remote Git remote to push to (default: origin) Examples $ np @@ -76,36 +87,40 @@ $ np --help Run `np` without arguments to launch the interactive UI that guides you through publishing a new version. - + ## Config -`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js` or `.np-config.json` file in the project directory. +`np` can be configured both globally and locally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` (as CJS), `.np-config.cjs`, `.np-config.mjs`, or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in one of the aforementioned file types in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. 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). -- `cleanup` - Cleanup `node_modules` (`true` by default). +- `branch` - Name of the release branch (`main` or `master` by default). +- `cleanup` - Delete `node_modules` before installing dependencies (`true` by default). Setting it to `false` only skips np's explicit cleanup step; the package manager install command still runs and may replace `node_modules` itself. Use `yolo` to skip install entirely. - `tests` - Run `npm test` (`true` by default). - `yolo` - Skip cleanup and testing (`false` by default). - `publish` - Publish (`true` by default). -- `preview` - Show tasks without actually executing them (`false` by default). +- `dryRun` - Show tasks without actually executing them (`false` by default). The CLI also accepts `--preview` as an alias. - `tag` - Publish under a given dist-tag (`latest` by default). -- `yarn` - Use yarn if possible (`true` by default). - `contents` - Subdirectory to publish (`.` by default). - `releaseDraft` - Open a GitHub release draft after releasing (`true` by default). +- `releaseNotes` - Auto-generate release notes when opening a GitHub release draft (`true` by default). - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). +- `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. +- `packageManager` - Set the package manager to be used. Defaults to the [`packageManager`](https://nodejs.org/api/packages.html#packagemanager) or [`devEngines.packageManager`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) field in package.json, so only use if you can't update package.json for some reason. +- `provenance` - Publish with [npm provenance statements](https://docs.npmjs.com/generating-provenance-statements) (`false` by default). Requires npm 9.5.0+ and a supported CI environment (GitHub Actions or GitLab CI/CD). +- `remote` - Git remote to push tags and commits to. Useful when publishing from a fork where `origin` is your fork and `upstream` is the main repository. -For example, this configures `np` to never use Yarn and to use `dist` as the subdirectory to publish: +For example, this configures `np` to use `unit-test` as a test script, and to use `dist` as the subdirectory to publish: `package.json` ```json { "name": "superb-package", "np": { - "yarn": false, + "testScript": "unit-test", "contents": "dist" } } @@ -114,15 +129,23 @@ For example, this configures `np` to never use Yarn and to use `dist` as the sub `.np-config.json` ```json { - "yarn": false, + "testScript": "unit-test", "contents": "dist" } ``` -`.np-config.js` +`.np-config.js` or `.np-config.cjs` ```js module.exports = { - yarn: false, + testScript: 'unit-test', + contents: 'dist' +}; +``` + +`.np-config.mjs` +```js +export default { + testScript: 'unit-test', contents: 'dist' }; ``` @@ -197,7 +220,7 @@ $ yarn config set version-sign-git-tag true ### Private packages - + You can use `np` for packages that aren't publicly published to npm (perhaps installed from a private git repo). @@ -214,6 +237,10 @@ To publish [scoped packages](https://docs.npmjs.com/misc/scope#publishing-public } ``` +If publishing a scoped package for the first time, `np` will prompt you to ask if you want to publish it publicly. + +**Note:** When publishing a scoped package, the first ever version you publish has to be done interactively using `np`. If not, you cannot use `np` to publish future versions of the package. + ### Private Org-scoped packages To publish a [private Org-scoped package](https://docs.npmjs.com/creating-and-publishing-an-org-scoped-package#publishing-a-private-org-scoped-package), you need to set the access level to `restricted`. You can do that by adding the following to your `package.json`: @@ -234,6 +261,10 @@ Set the [`registry` option](https://docs.npmjs.com/misc/config#registry) in pack } ``` +### Package managers + +If a package manager is not set in package.json (`packageManager` or `devEngines.packageManager`), via configuration (`packageManager`), or via the CLI (`--package-manager`), `np` will attempt to infer the best package manager to use by looking for lockfiles. But it's recommended to set the [`packageManager` field](https://nodejs.org/api/packages.html#packagemanager) in your package.json to be consistent with other tools. See also the [corepack docs](https://nodejs.org/api/corepack.html). + ### Publish with a CI If you use a Continuous Integration server to publish your tagged commits, use the `--no-publish` flag to skip the publishing step of `np`. @@ -242,8 +273,8 @@ If you use a Continuous Integration server to publish your tagged commits, use t To publish to `gh-pages` (or any other branch that serves your static assets), install [`branchsite`](https://github.com/enriquecaballero/branchsite), an `np`-like CLI tool aimed to complement `np`, and create an [npm "post" hook](https://docs.npmjs.com/misc/scripts) that runs after `np`. -``` -$ npm install --save-dev branchsite +```sh +npm install --save-dev branchsite ``` ```json @@ -280,6 +311,10 @@ Host * If you're running into other issues when using SSH, please consult [GitHub's support article](https://help.github.com/articles/connecting-to-github-with-ssh/). +### Ignore strategy + +The [ignore strategy](https://docs.npmjs.com/files/package.json#files), either maintained in the `files`-property in `package.json` or in `.npmignore`, is meant to help reduce the package size. To avoid broken packages caused by essential files being accidentally ignored, `np` prints out all the new and unpublished files added to Git. Test files and other [common files](https://docs.npmjs.com/files/package.json#files) that are never published are not considered. `np` assumes either a standard directory layout or a customized layout represented in the `directories` property in `package.json`. + ## FAQ ### I get an error when publishing my package through Yarn @@ -297,7 +332,7 @@ npm ERR! code E403 npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-package/collaborators?format=cli - Forbidden ``` -…please check whether the command `npm access ls-collaborators my-awesome-package` succeeds. If it doesn't, Yarn has overwritten your registry URL. To fix this, add the correct registry URL to `package.json`: +…please check whether the command `npm access list collaborators my-awesome-package` succeeds. If it doesn't, Yarn has overwritten your registry URL. To fix this, add the correct registry URL to `package.json`: ```json "publishConfig": { @@ -305,8 +340,60 @@ npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-p } ``` +### np hangs during the "Publishing package" step + +If `np` hangs indefinitely during publishing, common causes include: + +**Lifecycle scripts that don't exit** + +npm automatically runs lifecycle hooks like `prepublish`, `publish`, and `postpublish` during publishing. If these scripts don't exit (e.g., running in watch mode), `np` will hang. + +```json +{ + "scripts": { + "test": "vitest", + "publish": "npm run test && np" + } +} +``` + +**Solution**: Don't name your scripts `publish`, `prepublish`, or `postpublish` (these are reserved npm lifecycle hooks). Use names like `release` instead: + +```json +{ + "scripts": { + "test": "vitest run", + "release": "np" + } +} +``` + +**Tests running in watch mode** + +If your test script runs in watch mode, it won't exit after running tests. + +**Solution**: Ensure your test command exits after running: + +```json +{ + "scripts": { + "test": "vitest run", + "test:dev": "vitest" + } +} +``` + +**Registry configuration issues** + +A missing trailing slash in `.npmrc` registry configuration can cause hangs. + +**Solution**: Ensure registry URLs have a trailing slash: + +```npmrc +@ORG:registry=https://npm.pkg.github.com/ +``` + ## Maintainers - [Sindre Sorhus](https://github.com/sindresorhus) -- [Sam Verschueren](https://github.com/SamVerschueren) -- [Itai Steinherz](https://github.com/itaisteinherz) +- [Tommy Mitchell](https://github.com/tommy-mitchell) diff --git a/source/.npmignore b/source/.npmignore new file mode 100644 index 00000000..cd4efd8e --- /dev/null +++ b/source/.npmignore @@ -0,0 +1 @@ +*.d.ts diff --git a/source/cli-implementation.js b/source/cli-implementation.js new file mode 100755 index 00000000..829b0cbf --- /dev/null +++ b/source/cli-implementation.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +import path from 'node:path'; +import process from 'node:process'; +import logSymbols from 'log-symbols'; +import meow from 'meow'; +import updateNotifier from 'update-notifier'; +import isInteractive from 'is-interactive'; +import {gracefulExit} from 'exit-hook'; +import {getPackageManagerConfig} from './package-manager/index.js'; +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 {verifyGitTasks} from './git-tasks.js'; +import {getOidcProvider} from './npm/oidc.js'; +import {SEMVER_INCREMENTS} from './version.js'; +import ui from './ui.js'; +import np from './index.js'; + +/** @typedef {typeof cli} CLI */ + +const cli = meow(` + Usage + $ np + + Version can be: + ${SEMVER_INCREMENTS.join(' | ')} | 1.2.3 + + Options + --any-branch Allow publishing from any branch + --branch Name of the release branch (default: main | master) + --no-cleanup Skips np's node_modules cleanup step before install + --no-tests Skips tests + --yolo Skips cleanup and testing + --no-publish Skips publishing + --dry-run Show tasks without actually executing them + --tag Publish under a given dist-tag + --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 + --no-release-notes Skips generating release notes when opening a GitHub release draft + --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) + --package-manager Use a specific package manager (default: package.json packageManager/devEngines) + --provenance Publish with npm provenance statements (CI-only) + --remote Git remote to push to (default: origin) + + 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 +`, { + importMeta: import.meta, + booleanDefault: undefined, + allowUnknownFlags: false, + // Don't use `default` for flags - we apply defaults later so config can override them + flags: { + anyBranch: { + type: 'boolean', + }, + branch: { + type: 'string', + }, + cleanup: { + type: 'boolean', + }, + tests: { + type: 'boolean', + }, + yolo: { + type: 'boolean', + }, + publish: { + type: 'boolean', + }, + releaseDraft: { + type: 'boolean', + }, + releaseDraftOnly: { + type: 'boolean', + }, + releaseNotes: { + type: 'boolean', + }, + tag: { + type: 'string', + }, + packageManager: { + type: 'string', + }, + contents: { + type: 'string', + }, + dryRun: { + type: 'boolean', + aliases: ['preview'], + }, + testScript: { + type: 'string', + }, + '2fa': { + type: 'boolean', + }, + message: { + type: 'string', + }, + provenance: { + type: 'boolean', + }, + remote: { + type: 'string', + }, + }, +}); + +updateNotifier({pkg: cli.pkg}).notify(); + +/** @typedef {Awaited>['options']} Options */ + +async function getOptions() { + const initialConfig = await config(process.cwd()); + const contents = cli.flags.contents ?? initialConfig?.contents; + const packagePath = contents ? path.resolve(process.cwd(), contents) : process.cwd(); + + const {package_, rootDirectory} = await util.readPackage(packagePath); + const projectDirectory = contents ? process.cwd() : rootDirectory; + + const localConfig = projectDirectory === process.cwd() + ? initialConfig + : await config(projectDirectory); + + // Filter out undefined CLI flags (not provided by user) + const explicitCliFlags = Object.fromEntries(Object.entries(cli.flags).filter(([, value]) => value !== undefined)); + + // Merge: local config → explicit CLI flags → defaults + const mergedFlags = { + cleanup: true, + tests: true, + publish: true, + releaseDraft: true, + releaseNotes: true, + '2fa': true, + ...localConfig, + ...explicitCliFlags, + }; + + const {preview, ...flags} = mergedFlags; + flags.dryRun ??= preview; + + // Workaround for unintended auto-casing behavior from `meow`. + if ('2Fa' in flags) { + flags['2fa'] = flags['2Fa']; + } + + if (flags.packageManager) { + package_.packageManager = flags.packageManager; + } + + const packageManager = getPackageManagerConfig(projectDirectory, package_); + + if (packageManager.throwOnExternalRegistry && npm.isExternalRegistry(package_)) { + throw new Error(`External registry is not yet supported with ${packageManager.id}.`); + } + + const runPublish = !flags.releaseDraftOnly && flags.publish && !package_.private; + + const availability = runPublish + ? await npm.isPackageNameAvailable(package_) + : { + isAvailable: false, + isUnknown: false, + }; + + // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. + const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0); + + const branch = flags.branch ?? await git.defaultBranch(); + if (!flags.releaseDraftOnly) { + // Keep obvious Git failures ahead of the wizard, but do not replace the later Git task. + // The publish flow still needs a final check in case the repo changes while the user is prompting or logging in. + await verifyGitTasks({anyBranch: flags.anyBranch, branch, remote: flags.remote}); + } + + const options = await ui({ + ...flags, + packageManager, + runPublish, + availability, + version, + branch, + }, {package_, rootDirectory}); + + return { + options: {...options, packageManager}, + projectDirectory, + rootDirectory, + package_, + }; +} + +try { + const {options, projectDirectory, rootDirectory, package_} = await getOptions(); + + if (!options.confirm) { + gracefulExit(); + } + + // Check authentication early, before Listr starts (so login can be interactive) + if (options.runPublish) { + // Skip auth check if OIDC is available (will be handled by npm publish itself) + if (getOidcProvider()) { + console.log('OIDC authentication detected - skipping auth check'); + } else { + const externalRegistry = npm.isExternalRegistry(package_) + ? package_.publishConfig.registry + : false; + + try { + await npm.username({externalRegistry}); + } catch (error) { + if (error.isNotLoggedIn && isInteractive()) { + console.log('\nYou must be logged in to publish. Running `npm login`...\n'); + await npm.login({externalRegistry}); + } else { + throw error; + } + } + } + } + + console.log(); // Prints a newline for readability + const newPackage = await np(options.version.toString(), options, {package_, projectDirectory, rootDirectory}); + + if (options.dryRun || options.releaseDraftOnly) { + gracefulExit(); + } + + console.log(`\n ${newPackage.name} ${newPackage.version} published 🎉`); +} catch (error) { + if (error.name === 'ExitPromptError') { + process.exit(0); + } + + console.error(`\n${logSymbols.error} ${error?.stack ?? error}`); + gracefulExit(1); +} diff --git a/source/cli.js b/source/cli.js index eb9e0190..259300ce 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,147 +1,15 @@ #!/usr/bin/env node -'use strict'; -// eslint-disable-next-line import/no-unassigned-import -require('symbol-observable'); // Important: This needs to be first to prevent weird Observable incompatibilities -const logSymbols = require('log-symbols'); -const meow = require('meow'); -const updateNotifier = require('update-notifier'); -const hasYarn = require('has-yarn'); -const config = require('./config'); -const {isPackageNameAvailable} = require('./npm/util'); -const version = require('./version'); -const util = require('./util'); -const ui = require('./ui'); -const np = require('.'); +import {debuglog} from 'node:util'; +import importLocal from 'import-local'; +import isInstalledGlobally from 'is-installed-globally'; -const cli = meow(` - Usage - $ np +const log = debuglog('np'); - Version can be: - ${version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 - - Options - --any-branch Allow publishing from any branch - --branch Name of the release branch (default: 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 - --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) - - 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 -`, { - booleanDefault: undefined, - flags: { - anyBranch: { - type: 'boolean' - }, - branch: { - type: 'string' - }, - cleanup: { - type: 'boolean' - }, - tests: { - type: 'boolean' - }, - yolo: { - type: 'boolean' - }, - publish: { - type: 'boolean' - }, - releaseDraft: { - type: 'boolean' - }, - tag: { - type: 'string' - }, - yarn: { - type: 'boolean' - }, - contents: { - type: 'string' - }, - preview: { - type: 'boolean' - }, - testScript: { - type: 'string' - }, - '2fa': { - type: 'boolean' - } - } -}); - -updateNotifier({pkg: cli.pkg}).notify(); - -(async () => { - const pkg = util.readPkg(); - - const defaultFlags = { - cleanup: true, - tests: true, - publish: true, - releaseDraft: true, - yarn: hasYarn(), - '2fa': true - }; - - const localConfig = await config(); - - const flags = { - ...defaultFlags, - ...localConfig, - ...cli.flags - }; - - // Workaround for unintended auto-casing behavior from `meow`. - if ('2Fa' in flags) { - flags['2fa'] = flags['2Fa']; - } - - const runPublish = flags.publish && !pkg.private; - - const availability = flags.publish ? await isPackageNameAvailable(pkg) : { - isAvailable: false, - isUnknown: false - }; - - const version = cli.input.length > 0 ? cli.input[0] : false; - - const options = await ui({ - ...flags, - availability, - version, - runPublish - }, pkg); - - if (!options.confirm) { - process.exit(0); - } - - console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options); - - if (options.preview) { - return; +// Prefer the local installation +if (!importLocal(import.meta.url)) { + if (isInstalledGlobally) { + log('Using global install of np.'); } - console.log(`\n ${newPkg.name} ${newPkg.version} published 🎉`); -})().catch(error => { - console.error(`\n${logSymbols.error} ${error.message}`); - process.exit(1); -}); + await import('./cli-implementation.js'); +} diff --git a/source/config.js b/source/config.js index 11b77932..97b3e44b 100644 --- a/source/config.js +++ b/source/config.js @@ -1,21 +1,38 @@ -'use strict'; -const os = require('os'); -const isInstalledGlobally = require('is-installed-globally'); -const pkgDir = require('pkg-dir'); -const {cosmiconfig} = require('cosmiconfig'); +import os from 'node:os'; +import isInstalledGlobally from 'is-installed-globally'; +import {cosmiconfig} from 'cosmiconfig'; -module.exports = async () => { - const searchDir = isInstalledGlobally ? os.homedir() : await pkgDir(); - const searchPlaces = ['.np-config.json', '.np-config.js']; - if (!isInstalledGlobally) { - searchPlaces.push('package.json'); - } +export default async function getConfig(rootDirectory) { + const searchPlaces = [ + '.np-config.json', + '.np-config.js', + '.np-config.cjs', + '.np-config.mjs', + 'package.json', + ]; const explorer = cosmiconfig('np', { searchPlaces, - stopDir: searchDir + stopDir: rootDirectory, }); - const {config} = (await explorer.search(searchDir)) || {}; - return config; -}; + // Always read project config + const {config: projectConfig} = (await explorer.search(rootDirectory)) ?? {}; + + // When globally installed, also read global config and merge (project wins) + if (isInstalledGlobally) { + const globalExplorer = cosmiconfig('np', { + searchPlaces: searchPlaces.filter(place => place !== 'package.json'), + stopDir: os.homedir(), + }); + + const {config: globalConfig} = (await globalExplorer.search(os.homedir())) ?? {}; + + return { + ...globalConfig, + ...projectConfig, + }; + } + + return projectConfig; +} diff --git a/source/git-tasks.js b/source/git-tasks.js index 8f7376f6..4e426136 100644 --- a/source/git-tasks.js +++ b/source/git-tasks.js @@ -1,26 +1,50 @@ -'use strict'; -const Listr = require('listr'); -const git = require('./git-util'); +import Listr from 'listr'; +import * as git from './git-util.js'; -module.exports = options => { +const createGitTasks = options => { const tasks = [ { title: 'Check current branch', - task: () => git.verifyCurrentBranchIsReleaseBranch(options.branch) + task: () => git.verifyCurrentBranchIsReleaseBranch(options.branch), }, { title: 'Check local working tree', - task: () => git.verifyWorkingTreeIsClean() + task: () => git.verifyWorkingTreeIsClean(), }, { title: 'Check remote history', - task: () => git.verifyRemoteHistoryIsClean() - } + task: () => git.verifyRemoteHistoryIsClean(), + }, ]; if (options.anyBranch) { tasks.shift(); } - return new Listr(tasks); + return tasks; }; + +export const verifyGitTasks = async options => { + if (!options.anyBranch) { + await git.verifyCurrentBranchIsReleaseBranch(options.branch); + } + + await git.verifyWorkingTreeIsClean(); + if (options.remote) { + await git.verifyRemoteIsValid(options.remote); + } else if ( + !( + options.anyBranch + && await git.isHeadDetached() + ) + && await git.hasUpstream() + ) { + await git.verifyRemoteIsValid(await git.getUpstreamRemote()); + } + + await git.verifyRemoteHistoryIsClean(); +}; + +const gitTasks = options => new Listr(createGitTasks(options)); + +export default gitTasks; diff --git a/source/git-util.js b/source/git-util.js index b885fd7a..f69d3978 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -1,24 +1,101 @@ -'use strict'; -const execa = require('execa'); -const escapeStringRegexp = require('escape-string-regexp'); -const {verifyRequirementSatisfied} = require('./version'); +import path from 'node:path'; +import {execa} from 'execa'; +import escapeStringRegexp from 'escape-string-regexp'; +import ignoreWalker from 'ignore-walk'; +import semver from 'semver'; +import * as util from './util.js'; -exports.latestTag = async () => { +const gitNetworkTimeout = 120_000; // 2 minutes for remote git operations + +export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); return stdout; }; +export const root = async () => { + const {stdout} = await execa('git', ['rev-parse', '--show-toplevel']); + return stdout; +}; + +export const newFilesSinceLastRelease = async rootDirectory => { + try { + const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await latestTag(), 'HEAD']); + if (stdout.trim().length === 0) { + return []; + } + + const result = stdout.trim().split('\n').map(row => row.trim()); + return result; + } catch { + // Get all files under version control + return ignoreWalker({ + path: rootDirectory, + ignoreFiles: ['.gitignore'], + }); + } +}; + +export const readFileFromLastRelease = async 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 all tags. */ +const tagList = async () => { + const {stdout} = await execa('git', ['tag']); + return stdout ? stdout.split('\n') : []; +}; + +/** Returns an array of tags sorted by semver in ascending order. Non-semver tags are excluded. */ +const tagListSortedBySemver = async () => { + const tags = await tagList(); + return tags + .filter(tag => semver.valid(tag)) + .sort((a, b) => semver.compare(a, b)); +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); - return stdout; + // Repository may have multiple initial commits (e.g., from merging unrelated histories). + // Return just the first one. + return stdout.split('\n')[0]; }; -exports.latestTagOrFirstCommit = async () => { +export const previousTagOrFirstCommit = async () => { + const tags = await tagListSortedBySemver(); + + if (tags.length === 0) { + return; + } + + if (tags.length === 1) { + return firstCommit(); + } + + try { + // Return the tag before the latest one (sorted by semver). + const latest = await latestTag(); + const index = tags.indexOf(latest); + + if (index === -1 || index === 0) { + return firstCommit(); + } + + return tags[index - 1]; + } catch { + // Fallback to the first commit. + return firstCommit(); + } +}; + +export const latestTagOrFirstCommit = async () => { let latest; try { // In case a previous tag exists, we use it to compare the current repo status to. - latest = await exports.latestTag(); - } catch (_) { + latest = await latestTag(); + } catch { // Otherwise, we fallback to using the first commit for comparison. latest = await firstCommit(); } @@ -26,27 +103,42 @@ exports.latestTagOrFirstCommit = async () => { return latest; }; -exports.hasUpstream = async () => { - const escapedCurrentBranch = escapeStringRegexp(await exports.currentBranch()); +export const hasUpstream = async () => { + const escapedCurrentBranch = escapeStringRegexp(await getCurrentBranch()); const {stdout} = await execa('git', ['status', '--short', '--branch', '--porcelain']); return new RegExp(String.raw`^## ${escapedCurrentBranch}\.\.\..+\/${escapedCurrentBranch}`).test(stdout); }; -exports.currentBranch = async () => { +export const getCurrentBranch = async () => { const {stdout} = await execa('git', ['symbolic-ref', '--short', 'HEAD']); return stdout; }; -exports.verifyCurrentBranchIsReleaseBranch = async releaseBranch => { - const allowedBranches = releaseBranch ? [releaseBranch] : ['main', 'master']; - const currentBranch = await exports.currentBranch(); - if (!allowedBranches.includes(currentBranch)) { - throw new Error(`Not on ${allowedBranches.map(branch => `\`${branch}\``).join('/')} branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); +export const getUpstreamRemote = async () => { + const currentBranch = await getCurrentBranch(); + const {stdout} = await execa('git', ['config', `branch.${currentBranch}.remote`]); + return stdout; +}; + +export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { + const currentBranch = await getCurrentBranch(); + if (currentBranch !== releaseBranch) { + throw new Error(`Not on \`${releaseBranch}\` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); + } +}; + +export const isHeadDetached = async () => { + try { + // Command will fail with code 1 if the HEAD is detached. + await execa('git', ['symbolic-ref', '--quiet', 'HEAD']); + return false; + } catch { + return true; } }; -exports.isWorkingTreeClean = async () => { +const isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); if (status !== '') { @@ -54,50 +146,93 @@ exports.isWorkingTreeClean = async () => { } return true; - } catch (_) { + } catch { return false; } }; -exports.verifyWorkingTreeIsClean = async () => { - if (!(await exports.isWorkingTreeClean())) { +export const verifyWorkingTreeIsClean = async () => { + if (!(await isWorkingTreeClean())) { throw new Error('Unclean working tree. Commit or stash changes first.'); } }; -exports.isRemoteHistoryClean = async () => { - let history; - try { // Gracefully handle no remote set up. - const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); - history = stdout; - } catch (_) {} - - if (history && history !== '0') { +const hasRemote = async () => { + try { + await execa('git', ['rev-parse', '@{u}']); + } catch { // Has no remote if command fails return false; } return true; }; -exports.verifyRemoteHistoryIsClean = async () => { - if (!(await exports.isRemoteHistoryClean())) { +const hasUnfetchedChangesFromRemote = async () => { + // Inherit stdin to allow SSH password prompts for password-protected keys + const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {stdin: 'inherit', timeout: gitNetworkTimeout}); + + // There are unfetched changes if output is not empty. + return Boolean(possibleNewChanges); +}; + +const isRemoteHistoryClean = async () => { + const {stdout: history} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); + + // Remote history is clean if there are 0 revisions. + return history === '0'; +}; + +export const verifyRemoteHistoryIsClean = async () => { + if (!(await hasRemote())) { + return; + } + + if (await hasUnfetchedChangesFromRemote()) { + throw new Error('Remote history differs. Please run `git fetch` and pull changes.'); + } + + if (!(await isRemoteHistoryClean())) { throw new Error('Remote history differs. Please pull changes.'); } }; -exports.verifyRemoteIsValid = async () => { +export const verifyRemoteIsValid = async remote => { try { - await execa('git', ['ls-remote', 'origin', 'HEAD']); + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['ls-remote', remote ?? 'origin', 'HEAD'], {stdin: 'inherit', timeout: gitNetworkTimeout}); } catch (error) { throw new Error(error.stderr.replace('fatal:', 'Git fatal error:')); } }; -exports.fetch = async () => { - await execa('git', ['fetch']); +export const fetch = async () => { + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['fetch'], {stdin: 'inherit', timeout: gitNetworkTimeout}); +}; + +const hasLocalBranch = async branch => { + try { + await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); + return true; + } catch { + return false; + } +}; + +export const defaultBranch = async () => { + for (const branch of ['main', 'master', 'gh-pages']) { + // eslint-disable-next-line no-await-in-loop + if (await hasLocalBranch(branch)) { + return branch; + } + } + + throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); }; -exports.tagExistsOnRemote = async tagName => { +// Checks local refs after a prior `git fetch`. This is intentional over `git ls-remote` — the fetch +// syncs all refs and also catches local-only tags that haven't been pushed yet. +const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); @@ -117,24 +252,29 @@ exports.tagExistsOnRemote = async tagName => { } }; -exports.verifyTagDoesNotExistOnRemote = async tagName => { - if (await exports.tagExistsOnRemote(tagName)) { +export const verifyTagDoesNotExistOnRemote = async tagName => { + if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); } }; -exports.commitLogFromRevision = async revision => { +export const commitLogFromRevision = async revision => { const {stdout} = await execa('git', ['log', '--format=%s %h', `${revision}..HEAD`]); return stdout; }; -exports.pushGraceful = async remoteIsOnGitHub => { +const push = async (remote, tagArgument = '--follow-tags') => { + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['push', ...(remote ? [remote] : []), tagArgument], {stdin: 'inherit', timeout: gitNetworkTimeout}); +}; + +export const pushGraceful = async (remoteIsOnGitHub, remote) => { try { - await exports.push(); + await push(remote); } 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(remote, '--tags'); return {pushed: 'tags', reason: 'Branch protection: np can`t push the commits. Push them manually.'}; } @@ -142,15 +282,11 @@ exports.pushGraceful = async remoteIsOnGitHub => { } }; -exports.push = async () => { - await execa('git', ['push', '--follow-tags']); -}; - -exports.deleteTag = async tagName => { +export const deleteTag = async tagName => { await execa('git', ['tag', '--delete', tagName]); }; -exports.removeLastCommit = async () => { +export const removeLastCommit = async () => { await execa('git', ['reset', '--hard', 'HEAD~1']); }; @@ -160,8 +296,24 @@ const gitVersion = async () => { return match && match.groups.version; }; -exports.verifyRecentGitVersion = async () => { +export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); + util.validateEngineVersionSatisfies('git', installedVersion); +}; - verifyRequirementSatisfied('git', installedVersion); +export const verifyUserConfigIsSet = async () => { + const [nameResult, emailResult] = await Promise.allSettled([ + execa('git', ['config', 'user.name']), + execa('git', ['config', 'user.email']), + ]); + + if (nameResult.status !== 'fulfilled' || !nameResult.value.stdout || emailResult.status !== 'fulfilled' || !emailResult.value.stdout) { + throw new Error([ + 'Git user configuration is not set.', + '', + 'Please set your git user name and email:', + ' git config --global user.name "Your Name"', + ' git config --global user.email "you@example.com"', + ].join('\n')); + } }; diff --git a/source/index.js b/source/index.js index f0209fbb..ff74c6b6 100644 --- a/source/index.js +++ b/source/index.js @@ -1,62 +1,81 @@ -'use strict'; -require('any-observable/register/rxjs-all'); // eslint-disable-line import/no-unassigned-import -const fs = require('fs'); -const path = require('path'); -const execa = require('execa'); -const del = require('del'); -const Listr = require('listr'); -const split = require('split'); -const {merge, throwError} = require('rxjs'); -const {catchError, filter, finalize} = require('rxjs/operators'); -const streamToObservable = require('@samverschueren/stream-to-observable'); -const readPkgUp = require('read-pkg-up'); -const hasYarn = require('has-yarn'); -const pkgDir = require('pkg-dir'); -const hostedGitInfo = require('hosted-git-info'); -const onetime = require('onetime'); -const exitHook = require('async-exit-hook'); -const logSymbols = require('log-symbols'); -const prerequisiteTasks = require('./prerequisite-tasks'); -const gitTasks = require('./git-tasks'); -const publish = require('./npm/publish'); -const enable2fa = require('./npm/enable-2fa'); -const npm = require('./npm/util'); -const releaseTaskHelper = require('./release-task-helper'); -const util = require('./util'); -const git = require('./git-util'); - -const exec = (cmd, args) => { +import path from 'node:path'; +import {execa} from 'execa'; +import {deleteAsync} from 'del'; +// NOTE: We intentionally use the original `listr` package instead of `listr2`. +// listr2's DefaultRenderer uses log-update which has known issues with terminal scrolling +// that cause it to overwrite content printed before listr2 started (like our inquirer prompts). +// See: https://github.com/cenk1cenk2/listr2/issues/296 +import Listr from 'listr'; +import { + merge, + catchError, + filter, + finalize, + from, + mergeMap, + throwError, +} from 'rxjs'; +import hostedGitInfo from 'hosted-git-info'; +import onetime from 'onetime'; +import {asyncExitHook} from 'exit-hook'; +import logSymbols from 'log-symbols'; +import prerequisiteTasks from './prerequisite-tasks.js'; +import gitTasks from './git-tasks.js'; +import {getPackagePublishArguments, runPublish} from './npm/publish.js'; +import enable2fa, {getEnable2faArguments} from './npm/enable-2fa.js'; +import handleNpmError from './npm/handle-npm-error.js'; +import {getOidcProvider} from './npm/oidc.js'; +import releaseTaskHelper from './release-task-helper.js'; +import {findLockfile, printCommand} from './package-manager/index.js'; +import * as util from './util.js'; +import * as git from './git-util.js'; +import * as npm from './npm/util.js'; + +/** @type {(cmd: string, args: string[], options?: import('execa').Options) => any} */ +const exec = (command, arguments_, options) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 - const cp = execa(cmd, args); + const subProcess = execa(command, arguments_, options); + + return merge(subProcess.stdout, subProcess.stderr, subProcess).pipe( + filter(Boolean), + catchError(error => { + // Include stderr in error message for better diagnostics + if (error.stderr) { + error.message = `${error.shortMessage}\n${error.stderr}`; + } - return merge( - streamToObservable(cp.stdout.pipe(split())), - streamToObservable(cp.stderr.pipe(split())), - cp - ).pipe(filter(Boolean)); + throw error; + }), + ); }; -// eslint-disable-next-line default-param-last -module.exports = async (input = 'patch', options) => { - if (!hasYarn() && options.yarn) { - throw new Error('Could not use Yarn without yarn.lock file'); - } +/** +@param {string} input +@param {import('./cli-implementation.js').Options} options +@param {{package_: import('read-pkg').NormalizedPackageJson; projectDirectory?: string; rootDirectory: string}} context +*/ +const np = async (input = 'patch', {packageManager, ...rawOptions}, {package_, projectDirectory, rootDirectory}) => { + projectDirectory ??= rootDirectory; + + const {preview, ...options} = rawOptions; + options.dryRun ??= preview; // TODO: Remove sometime far in the future if (options.skipCleanup) { options.cleanup = false; } - const pkg = util.readPkg(options.contents); const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; - const pkgManager = options.yarn === true ? 'yarn' : 'npm'; - const pkgManagerName = options.yarn === true ? 'Yarn' : 'npm'; - const rootDir = pkgDir.sync(); - const hasLockFile = fs.existsSync(path.resolve(rootDir, options.yarn ? 'yarn.lock' : 'package-lock.json')) || fs.existsSync(path.resolve(rootDir, 'npm-shrinkwrap.json')); - const isOnGitHub = options.repoUrl && (hostedGitInfo.fromUrl(options.repoUrl) || {}).type === 'github'; + const runInstall = !options.yolo; + const lockfile = findLockfile(projectDirectory, packageManager); + const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; - const testCommand = options.testScript ? ['run', testScript] : [testScript]; + + if (options.releaseDraftOnly) { + await releaseTaskHelper(options, package_, packageManager); + return package_; + } let publishStatus = 'UNKNOWN'; let pushedObjects; @@ -64,14 +83,19 @@ module.exports = async (input = 'patch', options) => { const rollback = onetime(async () => { console.log('\nPublish failed. Rolling back to the previous state…'); - const tagVersionPrefix = await util.getTagVersionPrefix(options); + const tagVersionPrefix = await util.getTagVersionPrefix(packageManager); const latestTag = await git.latestTag(); const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); + async function getPackageVersion() { + const package_ = await util.readPackage(rootDirectory); + return package_.version; + } + try { - if (versionInLatestTag === util.readPkg().version && - versionInLatestTag !== pkg.version) { // Verify that the package's version has been bumped before deleting the last tag and commit. + // Verify that the package's version has been bumped before deleting the last tag and commit. + if (versionInLatestTag === await getPackageVersion() && versionInLatestTag !== package_.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } @@ -82,192 +106,206 @@ module.exports = async (input = 'patch', options) => { } }); - // The default parameter is a workaround for https://github.com/Tapppi/async-exit-hook/issues/9 - exitHook((callback = () => {}) => { - if (options.preview) { - callback(); - } else if (publishStatus === 'FAILED') { - (async () => { - await rollback(); - callback(); - })(); - } else if (publishStatus === 'SUCCESS') { - callback(); + asyncExitHook(async () => { + if (options.dryRun || publishStatus === 'SUCCESS') { + return; + } + + if (publishStatus === 'FAILED') { + await rollback(); } else { console.log('\nAborted!'); - callback(); } - }); + }, {wait: 2000}); + + // Don't enable 2FA when using OIDC (Trusted Publishing) as it's already managed + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !package_.private && !npm.isExternalRegistry(package_) && !getOidcProvider(); + + // To prevent the process from hanging due to watch mode (e.g. when running `vitest`) + const ciEnvOptions = {env: {CI: 'true'}}; + + /** @param {typeof options} _options */ + function getPublishCommand(_options) { + const publishCommand = packageManager.publishCommand || (arguments_ => [packageManager.cli, arguments_]); + const arguments_ = getPackagePublishArguments(_options); + return publishCommand(arguments_); + } + + function getInstallCommand() { + // `--no-cleanup` only skips np's explicit cleanup task. + // We still use the package manager's normal lockfile-aware install mode, + // even when that install mode replaces `node_modules` itself. + if (lockfile) { + return packageManager.installCommand; + } + + return packageManager.installCommandNoLockfile; + } const tasks = new Listr([ { title: 'Prerequisite check', enabled: () => options.runPublish, - task: () => prerequisiteTasks(input, pkg, options) + task: () => prerequisiteTasks(input, package_, options, {packageManager, rootDirectory}), }, { title: 'Git', - task: () => gitTasks(options) - } - ], { - showSubtasks: false - }); - - if (runCleanup) { - tasks.add([ - { - title: 'Cleanup', - enabled: () => !hasLockFile, - task: () => del('node_modules') - }, - { - title: 'Installing dependencies using Yarn', - enabled: () => options.yarn === true, - task: () => exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( - catchError(error => { - if (error.stderr.startsWith('error Your lockfile needs to be updated')) { - return throwError(new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.')); - } - - return throwError(error); - }) - ) - }, - { - title: 'Installing dependencies using npm', - enabled: () => options.yarn === false, - task: () => { - const args = hasLockFile ? ['ci'] : ['install', '--no-package-lock', '--no-production']; - return exec('npm', [...args, '--engine-strict']); + task: () => gitTasks(options), + }, + { + title: 'Cleanup', + enabled: () => runCleanup && !lockfile, + skip() { + if (options.dryRun) { + return '[Dry run] Command not executed: delete node_modules.'; } - } - ]); - } - - if (runTests) { - tasks.add([ - { - title: 'Running tests using npm', - enabled: () => options.yarn === false, - task: () => exec('npm', testCommand) }, - { - title: 'Running tests using Yarn', - enabled: () => options.yarn === true, - task: () => exec('yarn', testCommand).pipe( - catchError(error => { - if (error.message.includes(`Command "${testScript}" not found`)) { - return []; - } - - return throwError(error); - }) - ) - } - ]); - } - - tasks.add([ + task: () => deleteAsync(path.join(projectDirectory, 'node_modules')), + }, { - title: 'Bumping version using Yarn', - enabled: () => options.yarn === true, - skip: () => { - if (options.preview) { - return `[Preview] Command not executed: yarn version --new-version ${input}.`; + title: `Installing dependencies using ${packageManager.id}`, + enabled: () => runInstall, + skip() { + if (options.dryRun) { + return `[Dry run] Command not executed: ${printCommand(getInstallCommand())}.`; } }, - task: () => exec('yarn', ['version', '--new-version', input]) + task: () => new Listr([ + { + title: 'Running install command', + task() { + return exec(...getInstallCommand(), {cwd: projectDirectory}); + }, + }, + { + title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail + task: () => git.verifyWorkingTreeIsClean(), + }, + ]), }, { - title: 'Bumping version using npm', - enabled: () => options.yarn === false, - skip: () => { - if (options.preview) { - return `[Preview] Command not executed: npm version ${input}.`; + title: 'Running tests', + enabled: () => runTests, + skip() { + if (options.dryRun) { + return `[Dry run] Command not executed: ${packageManager.cli} run ${testScript}.`; } }, - task: () => exec('npm', ['version', input]) - } - ]); - - if (options.runPublish) { - tasks.add([ - { - title: `Publishing package using ${pkgManagerName}`, - skip: () => { - if (options.preview) { - const args = publish.getPackagePublishArguments(options); - return `[Preview] Command not executed: ${pkgManager} ${args.join(' ')}.`; + task: () => exec(packageManager.cli, ['run', testScript], {...ciEnvOptions, cwd: projectDirectory}), + }, + { + title: 'Bumping version', + skip() { + if (options.dryRun) { + const [cli, arguments_] = packageManager.versionCommand(input); + + if (options.message) { + arguments_.push('--message', options.message.replaceAll('%s', input)); } - }, - task: (context, task) => { - let hasError = false; - - return publish(context, pkgManager, task, options) - .pipe( - catchError(async error => { - hasError = true; - await rollback(); - throw new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`); - }), - finalize(() => { - publishStatus = hasError ? 'FAILED' : 'SUCCESS'; - }) - ); + + return `[Dry run] Command not executed: ${printCommand([cli, arguments_])}`; } - } - ]); + }, + task() { + const [cli, arguments_] = packageManager.versionCommand(input); - const isExternalRegistry = npm.isExternalRegistry(pkg); - if (options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !isExternalRegistry) { - tasks.add([ + if (options.message) { + arguments_.push('--message', options.message); + } + + // Inherit stdin to allow GPG password prompts for commit signing + return exec(cli, arguments_, {stdin: 'inherit'}); + }, + }, + ...options.runPublish + ? [ { - title: 'Enabling two-factor authentication', - skip: () => { - if (options.preview) { - const args = enable2fa.getEnable2faArgs(pkg.name, options); - return `[Preview] Command not executed: npm ${args.join(' ')}.`; + title: 'Publishing package', + skip() { + if (options.dryRun) { + const command = getPublishCommand(options); + return `[Dry run] Command not executed: ${printCommand(command)}.`; } }, - task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}) - } - ]); - } - } else { - publishStatus = 'SUCCESS'; - } + /** @type {(context, task) => Listr.ListrTaskResult} */ + task(context, task) { + let hasError = false; - tasks.add({ - title: 'Pushing tags', - skip: async () => { - if (!(await git.hasUpstream())) { - return 'Upstream branch not found; not pushing.'; - } + return runPublish(getPublishCommand(options), {cwd: rootDirectory}) + .pipe(catchError(error => handleNpmError(error, task, otp => { + context.otp = otp; - if (options.preview) { - return '[Preview] Command not executed: git push --follow-tags.'; - } + return runPublish(getPublishCommand({...options, otp}), {cwd: rootDirectory}); + }))) + .pipe( + // Note: Cannot use `async` here as the `await` will not finish before the error propagates. + catchError(error => { + hasError = true; + return from(rollback()).pipe( + mergeMap(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + catchError(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + ); + }), + finalize(() => { + publishStatus = hasError ? 'FAILED' : 'SUCCESS'; + }), + ); + }, + }, + ...shouldEnable2FA + ? [{ + title: 'Enabling two-factor authentication', + async skip() { + if (options.dryRun) { + const arguments_ = await getEnable2faArguments(package_.name, options); + return `[Dry run] Command not executed: npm ${arguments_.join(' ')}.`; + } + }, + task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), + }] + : [], + ] + : [], + { + title: 'Pushing tags', + async skip() { + if (!options.remote && !(await git.hasUpstream())) { + return 'Upstream branch not found; not pushing.'; + } - if (publishStatus === 'FAILED' && options.runPublish) { - return 'Couldn\'t publish package to npm; not pushing.'; - } - }, - task: async () => { - pushedObjects = await git.pushGraceful(isOnGitHub); - } - }); + if (options.dryRun) { + const remote = options.remote ? `${options.remote} ` : ''; + return `[Dry run] Command not executed: git push ${remote}--follow-tags.`; + } - if (options.releaseDraft) { - tasks.add({ - title: 'Creating release draft on GitHub', - enabled: () => isOnGitHub === true, - skip: () => { - if (options.preview) { - return '[Preview] GitHub Releases draft will not be opened in preview mode.'; + if (publishStatus === 'FAILED' && options.runPublish) { + return 'Couldn\'t publish package to npm; not pushing.'; } }, - task: () => releaseTaskHelper(options, pkg) - }); + async task() { + pushedObjects = await git.pushGraceful(isOnGitHub, options.remote); + }, + }, + ...options.releaseDraft + ? [{ + title: 'Creating release draft on GitHub', + enabled: () => isOnGitHub === true, + skip() { + if (options.dryRun) { + return '[Dry run] GitHub Releases draft will not be opened in dry-run mode.'; + } + }, + task: () => releaseTaskHelper(options, package_, packageManager), + }] + : [], + ], { + showSubtasks: false, + renderer: options.renderer ?? 'default', + clearOutput: !options.dryRun && !options.releaseDraftOnly, + }); + + if (!options.runPublish) { + publishStatus = 'SUCCESS'; } await tasks.run(); @@ -276,6 +314,8 @@ module.exports = async (input = 'patch', options) => { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } - const {packageJson: newPkg} = await readPkgUp(); - return newPkg; + const {package_: newPackage} = await util.readPackage(); + return newPackage; }; + +export default np; diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index a9e2ef34..25ca84de 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -1,24 +1,24 @@ -'use strict'; -const execa = require('execa'); -const {from} = require('rxjs'); -const {catchError} = require('rxjs/operators'); -const handleNpmError = require('./handle-npm-error'); +import {execa} from 'execa'; +import {from, catchError} from 'rxjs'; +import Version from '../version.js'; +import handleNpmError from './handle-npm-error.js'; +import {npmNetworkTimeout, version as npmVersionCheck} from './util.js'; -const getEnable2faArgs = (packageName, options) => { - const args = ['access', '2fa-required', packageName]; +export const getEnable2faArguments = async (packageName, options) => { + const npmVersion = await npmVersionCheck(); + const arguments_ = 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); + arguments_.push('--otp', options.otp); } - return args; + return arguments_; }; -const enable2fa = (packageName, options) => execa('npm', getEnable2faArgs(packageName, options)); +const enable2fa = async (packageName, options) => execa('npm', await getEnable2faArguments(packageName, options), {timeout: npmNetworkTimeout}); -module.exports = (task, packageName, options) => - from(enable2fa(packageName, options)).pipe( - catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp}))) - ); +const tryEnable2fa = (task, packageName, options) => from(enable2fa(packageName, options)).pipe(catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp})))); -module.exports.getEnable2faArgs = getEnable2faArgs; +export default tryEnable2fa; diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 36586c93..abaa3a61 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -1,7 +1,6 @@ -const listrInput = require('listr-input'); -const chalk = require('chalk'); -const {throwError} = require('rxjs'); -const {catchError} = require('rxjs/operators'); +import listrInput from 'listr-input'; +import chalk from 'chalk'; +import {throwError, catchError} from 'rxjs'; const handleNpmError = (error, task, message, executor) => { if (typeof message === 'function') { @@ -10,22 +9,34 @@ const handleNpmError = (error, task, message, executor) => { } // `one-time pass` is for npm and `Two factor authentication` is for Yarn. - if (error.stderr.includes('one-time pass') || error.stdout.includes('Two factor authentication')) { + if ( + error.stderr.includes('one-time pass') // Npm + || error.stdout.includes('Two factor authentication') // Yarn v1 + || error.stdout.includes('One-time password:') // Yarn berry + ) { const {title} = task; task.title = `${title} ${chalk.yellow('(waiting for input…)')}`; return listrInput('Enter OTP:', { - done: otp => { + done(otp) { task.title = title; return executor(otp); }, - autoSubmit: value => value.length === 6 - }).pipe( - catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor)) - ); + autoSubmit: value => value.length === 6, + }).pipe(catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor))); } - return throwError(error); + // 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') // Npm/pnpm + || error.stdout.includes('Response Code: 402 (Payment Required)') // Yarn Berry + ) { + throw new Error('You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'); + } + + return throwError(() => error); }; -module.exports = handleNpmError; +export default handleNpmError; diff --git a/source/npm/oidc.js b/source/npm/oidc.js new file mode 100644 index 00000000..99387afb --- /dev/null +++ b/source/npm/oidc.js @@ -0,0 +1,28 @@ +import process from 'node:process'; + +const oidcProviders = [ + { + id: 'github', + name: 'GitHub Actions', + // See https://github.com/npm/cli/blob/7da8fdd3625dd5541af57052c90fe1eabb41eb96/lib/utils/oidc.js#L49-L67 + // See https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings + validate: () => + process.env.GITHUB_ACTIONS + && process.env.ACTIONS_ID_TOKEN_REQUEST_URL + && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + }, + { + id: 'gitlab', + name: 'GitLab CI', + // See https://github.com/npm/cli/blob/7da8fdd3625dd5541af57052c90fe1eabb41eb96/lib/utils/oidc.js#L37-L47 + validate: () => process.env.GITLAB_CI && process.env.NPM_ID_TOKEN, + }, +]; + +export const getOidcProvider = () => { + for (const provider of oidcProviders) { + if (provider.validate()) { + return provider.id; + } + } +}; diff --git a/source/npm/publish.js b/source/npm/publish.js index 0a4ed4ee..dbb3a967 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,40 +1,93 @@ -'use strict'; -const execa = require('execa'); -const {from} = require('rxjs'); -const {catchError} = require('rxjs/operators'); -const handleNpmError = require('./handle-npm-error'); +import {execa} from 'execa'; +import {merge, filter, catchError} from 'rxjs'; +import open from 'open'; -const getPackagePublishArguments = options => { - const args = ['publish']; - - if (options.contents) { - args.push(options.contents); - } +export const getPackagePublishArguments = options => { + const arguments_ = ['publish']; if (options.tag) { - args.push('--tag', options.tag); + arguments_.push('--tag', options.tag); } if (options.otp) { - args.push('--otp', options.otp); + arguments_.push('--otp', options.otp); } if (options.publishScoped) { - args.push('--access', 'public'); + arguments_.push('--access', 'public'); + } + + if (options.provenance) { + arguments_.push('--provenance'); } - return args; + return arguments_; }; -const pkgPublish = (pkgManager, options) => execa(pkgManager, getPackagePublishArguments(options)); +// 3 minutes timeout for publish operations (like git network operations) +// Publishing can take time for large packages or slow connections +const publishTimeout = 180_000; -module.exports = (context, pkgManager, task, options) => - from(pkgPublish(pkgManager, options)).pipe( - catchError(error => handleNpmError(error, task, otp => { - context.otp = otp; +export function runPublish(arguments_, options = {}) { + const execaOptions = { + stdin: 'pipe', + // Timeout to prevent infinite hangs (e.g., from lifecycle scripts in watch mode) + timeout: publishTimeout, + }; - return pkgPublish(pkgManager, {...options, otp}); - })) - ); + // `npm` 8.5+ has a bug where `npm publish ` publishes from cwd instead of . + // We work around this by changing cwd to the target directory. + // https://github.com/npm/cli/issues/5136 + if (options.cwd) { + execaOptions.cwd = options.cwd; + } + + const subprocess = execa(...arguments_, execaOptions); + + let outputBuffer = ''; -module.exports.getPackagePublishArguments = getPackagePublishArguments; + const handleAuthPrompt = data => { + outputBuffer += data.toString(); + + // Detect npm's browser authentication prompt + // Example: "Authenticate your account at:\nhttps://www.npmjs.com/auth/cli/xyz" + if (outputBuffer.includes('Authenticate your account at:')) { + const urlMatch = outputBuffer.match(/https:\/\/www\.npmjs\.com\/auth\/cli\/\S+/); + if (urlMatch) { + const authUrl = urlMatch[0]; + // Auto-open browser for authentication (ignore errors if browser fails to open) + (async () => { + try { + await open(authUrl); + } catch {} + })(); + + // Automatically send ENTER to continue (skip "Press ENTER" prompt) + subprocess.stdin?.write('\n'); + // Clear buffer after handling to prevent repeated triggers + outputBuffer = ''; + } + } + + // Prevent buffer from growing indefinitely + if (outputBuffer.length > 10_000) { + outputBuffer = outputBuffer.slice(-5000); + } + }; + + // Monitor both stdout and stderr for the authentication prompt + subprocess.stdout?.on('data', handleAuthPrompt); + subprocess.stderr?.on('data', handleAuthPrompt); + + return merge(subprocess.stdout, subprocess.stderr, subprocess).pipe( + filter(Boolean), + catchError(error => { + // Include stderr in error message for better diagnostics + if (error.stderr) { + error.message = `${error.shortMessage}\n${error.stderr}`; + } + + throw error; + }), + ); +} diff --git a/source/npm/util.js b/source/npm/util.js index f7011b2d..f537e517 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -1,76 +1,161 @@ -'use strict'; -const fs = require('fs'); -const path = require('path'); -const execa = require('execa'); -const pTimeout = require('p-timeout'); -const ow = require('ow'); -const npmName = require('npm-name'); -const chalk = require('chalk'); -const pkgDir = require('pkg-dir'); -const {verifyRequirementSatisfied} = require('../version'); - -exports.checkConnection = () => pTimeout( - (async () => { - try { - await execa('npm', ['ping']); - return true; - } catch (_) { - throw new Error('Connection to npm registry failed'); - } - })(), - 15000, - 'Connection to npm registry timed out' -); +import path from 'node:path'; +import {pathExists} from 'path-exists'; +import {execa} from 'execa'; +import npmName from 'npm-name'; +import chalk from 'chalk-template'; +import * as util from '../util.js'; -exports.username = async ({externalRegistry}) => { - const args = ['whoami']; +export const version = async () => { + const {stdout} = await execa('npm', ['--version']); + return stdout; +}; + +export const npmNetworkTimeout = 15_000; // 15 seconds for npm registry calls + +const throwIfNpmTimeout = error => { + if (error.timedOut) { + error.message = 'Connection to npm registry timed out'; + throw error; + } +}; + +export const checkConnection = async () => { + try { + await execa('npm', ['ping'], {timeout: npmNetworkTimeout}); + return true; + } catch (error) { + throwIfNpmTimeout(error); + throw new Error('Connection to npm registry failed'); + } +}; + +export const username = async ({externalRegistry}) => { + const arguments_ = ['whoami']; if (externalRegistry) { - args.push('--registry', externalRegistry); + arguments_.push('--registry', externalRegistry); } try { - const {stdout} = await execa('npm', args); + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); return stdout; } catch (error) { - throw new Error(/ENEEDAUTH/.test(error.stderr) ? - 'You must be logged in. Use `npm login` and try again.' : - 'Authentication error. Use `npm whoami` to troubleshoot.'); + throwIfNpmTimeout(error); + const isNotLoggedIn = /ENEEDAUTH|E401/.test(error.stderr); + const message = isNotLoggedIn + ? 'You must be logged in. Use `npm login` and try again.' + : 'Authentication error. Use `npm whoami` to troubleshoot.'; + const authError = new Error(message); + authError.isNotLoggedIn = isNotLoggedIn; + throw authError; + } +}; + +export const login = async ({externalRegistry}) => { + const arguments_ = ['login']; + + if (externalRegistry) { + arguments_.push('--registry', externalRegistry); + } + + try { + await execa('npm', arguments_, { + stdin: 'inherit', + stdout: 'inherit', + stderr: 'pipe', + }); + } catch (error) { + // User canceled the login prompt + if (error.stderr?.includes('canceled')) { + const cancelError = new Error('Login canceled'); + cancelError.name = 'ExitPromptError'; + throw cancelError; + } + + throw error; } }; -exports.collaborators = async pkg => { - const packageName = pkg.name; - ow(packageName, ow.string); +const NPM_DEFAULT_REGISTRIES = new Set([ + // https://docs.npmjs.com/cli/v10/using-npm/registry + 'https://registry.npmjs.org', + // https://docs.npmjs.com/cli/v10/commands/npm-profile#registry + 'https://registry.npmjs.org/', +]); +export const isExternalRegistry = package_ => { + const registry = package_.publishConfig?.registry; + if (typeof registry !== 'string') { + return false; + } + + const normalizedRegistry = registry.trim(); + const httpsVariant = normalizedRegistry.replace(/^http:\/\//, 'https://'); - const args = ['access', 'ls-collaborators', packageName]; - if (exports.isExternalRegistry(pkg)) { - args.push('--registry', pkg.publishConfig.registry); + return !NPM_DEFAULT_REGISTRIES.has(normalizedRegistry) + && !NPM_DEFAULT_REGISTRIES.has(httpsVariant); +}; + +export const collaborators = async package_ => { + const packageName = package_.name; + util.assert(typeof packageName === 'string', 'Package name is required'); + + const arguments_ = ['access', 'list', 'collaborators', packageName, '--json']; + + if (package_.publishConfig?.registry) { + arguments_.push('--registry', package_.publishConfig.registry); } try { - const {stdout} = await execa('npm', args); + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); return stdout; } catch (error) { + throwIfNpmTimeout(error); + // Ignore non-existing package error if (error.stderr.includes('code E404')) { return false; } + // External registries often don't support this endpoint, so ignore errors. + // The whoami check is sufficient for verifying authentication. + // See: https://github.com/sindresorhus/np/issues/420 + if (isExternalRegistry(package_)) { + return false; + } + throw error; } }; -exports.prereleaseTags = async packageName => { - ow(packageName, ow.string); +export const prereleaseTags = async packageName => { + util.assert(typeof packageName === 'string', 'Package name is required'); let tags = []; try { - const {stdout} = await execa('npm', ['view', '--json', packageName, 'dist-tags']); + const {stdout} = await execa('npm', ['view', '--json', packageName, 'dist-tags'], {timeout: npmNetworkTimeout}); tags = Object.keys(JSON.parse(stdout)) .filter(tag => tag !== 'latest'); } catch (error) { - if (((JSON.parse(error.stdout) || {}).error || {}).code !== 'E404') { + throwIfNpmTimeout(error); + // HACK: NPM is mixing JSON with plain text errors. Luckily, the error + // always starts with 'npm ERR!' (npm <10) or 'npm error' (npm >=10) + // so as a solution, until npm/cli#2740 is fixed, we can remove anything + // starting with 'npm ERR!' or 'npm error' + /** @type {string} */ + const errorMessage = error.stderr; + const errorJSON = errorMessage + .split('\n') + .filter(line => !line.startsWith('npm ERR!') && !line.startsWith('npm error')) + .join('\n'); + + try { + const parsed = JSON.parse(errorJSON); + // Only handle E404 errors gracefully; throw all other errors + if (parsed?.error?.code !== 'E404') { + throw error; + } + } catch { + // If JSON parsing fails, we can't determine the error type, so throw the original error throw error; } } @@ -82,21 +167,21 @@ exports.prereleaseTags = async packageName => { return tags; }; -exports.isPackageNameAvailable = async pkg => { - const args = [pkg.name]; +export const isPackageNameAvailable = async package_ => { + const arguments_ = [package_.name]; const availability = { isAvailable: false, - isUnknown: false + isUnknown: false, }; - if (exports.isExternalRegistry(pkg)) { - args.push({ - registryUrl: pkg.publishConfig.registry + if (isExternalRegistry(package_)) { + arguments_.push({ + registryUrl: package_.publishConfig.registry, }); } try { - availability.isAvailable = await npmName(...args) || false; + availability.isAvailable = await npmName(...arguments_) || false; } catch { availability.isUnknown = true; } @@ -104,36 +189,167 @@ exports.isPackageNameAvailable = async pkg => { return availability; }; -exports.isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; +export const verifyRecentNpmVersion = async () => { + const npmVersion = await version(); + util.validateEngineVersionSatisfies('npm', npmVersion); +}; -exports.version = async () => { - const {stdout} = await execa('npm', ['--version']); - return stdout; +export const checkIgnoreStrategy = async ({files}, rootDirectory) => { + const npmignoreExistsInPackageRootDirectory = await pathExists(path.resolve(rootDirectory, '.npmignore')); + + if (!files && !npmignoreExistsInPackageRootDirectory) { + 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. + `); + } +}; + +export const getFilesToBePacked = async rootDirectory => { + const {stdout} = await execa('npm', [ + 'pack', + '--dry-run', + '--json', + '--silent', + '--ignore-scripts', + // TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved. + '--foreground-scripts=false', + ], {cwd: rootDirectory}); + + try { + // HACK: NPM lifecycle scripts can output text even with --silent and --foreground-scripts=false. + // For example, Husky's prepare script outputs "> package@version prepare" and "> husky install". + // We extract only the JSON portion by finding the first '[' character. + // Related: https://github.com/sindresorhus/np/issues/742 + const {files} = JSON.parse(stdout.slice(Math.max(0, stdout.indexOf('[')))).at(0); + return files.map(file => file.path); + } catch (error) { + throw new Error('Failed to parse output of npm pack', {cause: error}); + } }; -exports.verifyRecentNpmVersion = async () => { - const npmVersion = await exports.version(); - verifyRequirementSatisfied('npm', npmVersion); +const hasPackLifecycleScript = package_ => { + const {scripts} = package_; + + if (typeof scripts !== 'object' || scripts === null) { + return false; + } + + return [ + 'prepare', + 'prepack', + 'prepublish', + 'prepublishOnly', + ].some(scriptName => typeof scripts[scriptName] === 'string'); }; -exports.checkIgnoreStrategy = ({files}) => { - const rootDir = pkgDir.sync(); - const npmignoreExists = fs.existsSync(path.resolve(rootDir, '.npmignore')); +const isValidEntryPoint = value => typeof value === 'string' && !value.includes('*'); - if (!files && !npmignoreExists) { - 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. - `); +const getExportsFiles = exports => { + const files = []; + + const extract = value => { + if (isValidEntryPoint(value)) { + files.push(value); + } else if (typeof value === 'object' && value !== null) { + for (const subvalue of Object.values(value)) { + extract(subvalue); + } + } + }; + + extract(exports); + return files; +}; + +export const getPackageEntryPoints = package_ => { + const entryPoints = []; + + if (isValidEntryPoint(package_.main)) { + entryPoints.push({field: 'main', path: package_.main}); + } + + if (typeof package_.bin === 'string') { + if (isValidEntryPoint(package_.bin)) { + entryPoints.push({field: 'bin', path: package_.bin}); + } + } else if (typeof package_.bin === 'object' && package_.bin !== null) { + for (const [name, binPath] of Object.entries(package_.bin)) { + if (isValidEntryPoint(binPath)) { + entryPoints.push({field: `bin.${name}`, path: binPath}); + } + } + } + + if (package_.exports) { + for (const file of getExportsFiles(package_.exports)) { + entryPoints.push({field: 'exports', path: file}); + } + } + + return entryPoints; +}; + +export const verifyPackageEntryPoints = async (package_, rootDirectory) => { + const packedFiles = new Set(await getFilesToBePacked(rootDirectory)); + const entryPoints = getPackageEntryPoints(package_); + + const seenPaths = new Set(); + const missingEntryPoints = []; + + for (const entryPoint of entryPoints) { + const normalizedPath = entryPoint.path.replace(/^\.\//, ''); + + if (seenPaths.has(normalizedPath)) { + continue; + } + + seenPaths.add(normalizedPath); + + if (!packedFiles.has(normalizedPath)) { + missingEntryPoints.push(entryPoint); + } + } + + if (missingEntryPoints.length > 0) { + if (hasPackLifecycleScript(package_)) { + return; + } + + const missing = missingEntryPoints.map(({field, path: entryPath}) => ` "${field}": ${entryPath}`).join('\n'); + throw new Error(`Missing entry points in published files:\n${missing}\n\nEnsure these files exist and are included in the "files" field.`); } }; -exports.getRegistryUrl = async (pkgManager, pkg) => { - const args = ['config', 'get', 'registry']; - if (exports.isExternalRegistry(pkg)) { - args.push('--registry'); - args.push(pkg.publishConfig.registry); +export const getPublishedPackageEngines = async package_ => { + const arguments_ = ['view', '--json', package_.name, 'engines']; + + if (package_.publishConfig?.registry) { + arguments_.push('--registry', package_.publishConfig.registry); } - const {stdout} = await execa(pkgManager, args); - return stdout; + try { + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); + + // Handle empty response (package exists but has no engines field) + if (stdout.trim() === '') { + return undefined; + } + + return JSON.parse(stdout); + } catch (error) { + throwIfNpmTimeout(error); + + // Package doesn't exist yet (first publish) + if (error.stderr?.includes('E404')) { + return undefined; + } + + // External registries often don't support this endpoint, so ignore errors. + // See: https://github.com/sindresorhus/np/issues/420 + if (isExternalRegistry(package_)) { + return undefined; + } + + throw error; + } }; diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js new file mode 100644 index 00000000..e5d82608 --- /dev/null +++ b/source/package-manager/configs.js @@ -0,0 +1,69 @@ +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const npmConfig = { + cli: 'npm', + id: 'npm', + installCommand: ['npm', ['ci', '--engine-strict']], + installCommandNoLockfile: ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict']], + versionCommand: version => ['npm', ['version', version]], + getRegistryCommand: ['npm', ['config', 'get', 'registry', '--workspaces=false']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], + lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const pnpmConfig = { + cli: 'pnpm', + id: 'pnpm', + installCommand: ['pnpm', ['install']], + installCommandNoLockfile: ['pnpm', ['install']], + versionCommand: version => ['pnpm', ['version', version]], + // By default, pnpm config returns `undefined` instead of `v` for tag-version-prefix, so for consistent default behavior, use npm. + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], + // Disable duplicated pnpm Git checks + publishCommand: arguments_ => ['pnpm', [...arguments_, '--no-git-checks']], + getRegistryCommand: ['pnpm', ['config', 'get', 'registry']], + lockfiles: ['pnpm-lock.yaml'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const yarnConfig = { + cli: 'yarn', + id: 'yarn', + installCommand: ['yarn', ['install', '--frozen-lockfile', '--production=false']], + installCommandNoLockfile: ['yarn', ['install', '--production=false']], + getRegistryCommand: ['yarn', ['config', 'get', 'registry']], + tagVersionPrefixCommand: ['yarn', ['config', 'get', 'version-tag-prefix']], + versionCommand: version => ['yarn', ['version', '--new-version', version]], + lockfiles: ['yarn.lock'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const yarnBerryConfig = { + cli: 'yarn', + id: 'yarn-berry', + installCommand: ['yarn', ['install', '--immutable']], + installCommandNoLockfile: ['yarn', ['install']], + // Yarn berry doesn't support git committing/tagging, so we use npm instead + versionCommand: version => ['npm', ['version', version]], + tagVersionPrefixCommand: ['yarn', ['config', 'get', 'version-tag-prefix']], + // Yarn berry offloads publishing to npm, e.g. `yarn npm publish x.y.z` + publishCommand: arguments_ => ['yarn', ['npm', ...arguments_]], + getRegistryCommand: ['yarn', ['config', 'get', 'npmRegistryServer']], + throwOnExternalRegistry: true, + lockfiles: ['yarn.lock'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const bunConfig = { + cli: 'bun', + id: 'bun', + installCommand: ['bun', ['install', '--frozen-lockfile']], + installCommandNoLockfile: ['bun', ['install', '--no-save']], + versionCommand: version => ['npm', ['version', version]], + // Bun doesn't support publishing, so we use npm instead. See https://github.com/oven-sh/bun/issues/5050 + publishCommand: arguments_ => ['npm', arguments_], + // TODO: Bun doesn't support config get registry, this should be added in the future. See https://github.com/oven-sh/bun/issues/7140 + getRegistryCommand: ['npm', ['config', 'get', 'registry', '--workspaces=false']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], + lockfiles: ['bun.lockb', 'bun.lock'], +}; diff --git a/source/package-manager/index.js b/source/package-manager/index.js new file mode 100644 index 00000000..5b8a9603 --- /dev/null +++ b/source/package-manager/index.js @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import * as configs from './configs.js'; + +/** +@param {string} rootDirectory +@param {import('./types.d.ts').PackageManagerConfig} config +*/ +export function findLockfile(rootDirectory, config) { + return config.lockfiles + .map(filename => path.resolve(rootDirectory || '.', filename)) + .find(filepath => fs.existsSync(filepath)); +} + +/** +@param {string} rootDirectory +@param {import('read-pkg').NormalizedPackageJson} package_ +*/ +export function getPackageManagerConfig(rootDirectory, package_) { + const config = configFromPackageManagerField(package_) ?? configFromDevEnginesPackageManager(package_); + return config || configFromLockfile(rootDirectory) || configs.npmConfig; +} + +/** @param {import('read-pkg').NormalizedPackageJson} package_ */ +function configFromPackageManagerField(package_) { + if (typeof package_.packageManager !== 'string') { + return undefined; + } + + const [packageManager, version] = package_.packageManager.split('@'); + + return configFromPackageManager(packageManager, version, package_.packageManager); +} + +/** @param {import('read-pkg').NormalizedPackageJson} package_ */ +function configFromDevEnginesPackageManager(package_) { + const {packageManager} = package_.devEngines ?? {}; + if (packageManager === undefined) { + return undefined; + } + + const packageManagers = Array.isArray(packageManager) ? packageManager : [packageManager]; + if (packageManagers.length === 0) { + throw new Error('Missing "name" property for "packageManager".'); + } + + for (const packageManager of packageManagers) { + if (!packageManager || typeof packageManager !== 'object' || !('name' in packageManager) || typeof packageManager.name !== 'string') { + throw new Error('Missing "name" property for "packageManager".'); + } + } + + const [packageManager_] = packageManagers; + const version = typeof packageManager_.version === 'string' ? packageManager_.version : undefined; + return configFromPackageManager(packageManager_.name, version, packageManager_.name); +} + +function configFromPackageManager(packageManager, version, rawPackageManager) { + const minimumVersion = version && semver.minVersion(version); + + if (packageManager === 'yarn' && minimumVersion && semver.gte(minimumVersion, '2.0.0')) { + return configs.yarnBerryConfig; + } + + if (packageManager === 'npm') { + return configs.npmConfig; + } + + if (packageManager === 'pnpm') { + return configs.pnpmConfig; + } + + if (packageManager === 'yarn') { + return configs.yarnConfig; + } + + if (packageManager === 'bun') { + return configs.bunConfig; + } + + throw new Error(`Invalid package manager: ${rawPackageManager}`); +} + +/** @param {string} rootDirectory */ +function configFromLockfile(rootDirectory, options = [configs.npmConfig, configs.pnpmConfig, configs.yarnConfig]) { + const foundConfig = options.find(config => findLockfile(rootDirectory, config)); + + // If yarn.lock is found, check if it's Yarn Berry by looking for .yarnrc.yml + if (foundConfig === configs.yarnConfig) { + const yarnrcYmlPath = path.resolve(rootDirectory || '.', '.yarnrc.yml'); + if (fs.existsSync(yarnrcYmlPath)) { + return configs.yarnBerryConfig; + } + } + + return foundConfig; +} + +/** @param {import('./types.d.ts').Command} command */ +export function printCommand([cli, arguments_]) { + return `${cli} ${arguments_.join(' ')}`; +} diff --git a/source/package-manager/types.d.ts b/source/package-manager/types.d.ts new file mode 100644 index 00000000..7c18ddd1 --- /dev/null +++ b/source/package-manager/types.d.ts @@ -0,0 +1,58 @@ +export type PackageManager = 'npm' | 'yarn' | 'pnpm'; + +/** +CLI and arguments, which can be passed to `execa`. +*/ +export type Command = [cli: string, args: string[]]; + +export type PackageManagerConfig = { + /** + The main CLI, e.g. the `npm` in `npm install`, `npm test`, etc. + */ + cli: PackageManager; + + /** + How the package manager should be referred to in user-facing messages (since there are two different configs for some, e.g. yarn and yarn-berry). + */ + id: string; + + /** + How to install packages when there is a lockfile, e.g. `["npm", ["install"]]`. + */ + installCommand: Command; + + /** + How to install packages when there is no lockfile, e.g. `["npm", ["install"]]`. + */ + installCommandNoLockfile: Command; + + /** + Given a version string, return a version command e.g. `version => ["npm", ["version", version]]`. + */ + versionCommand: (version: string) => [cli: string, args: string[]]; + + /** + Modify the actual publish command. Defaults to `args => [config.cli, args]`. + */ + publishCommand?: (arguments_: string[]) => Command; + + /** + CLI command which is expected to output the npm registry to use, e.g. `['npm', ['config', 'get', 'registry']]`. + */ + getRegistryCommand: Command; + + /** + CLI command expected to output the version tag prefix (often "v"). e,g. `['npm', ['config', 'get', 'tag-version-prefix']]`. + */ + tagVersionPrefixCommand: Command; + + /** + Set to true if the package manager doesn't support external registries. `np` will throw if one is detected and this is set. + */ + throwOnExternalRegistry?: boolean; + + /** + List of lockfile names expected for this package manager, relative to CWD. e.g. `['package-lock.json', 'npm-shrinkwrap.json']`. + */ + lockfiles: string[]; +}; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 065c6a35..491b2c20 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -1,42 +1,43 @@ -'use strict'; -const Listr = require('listr'); -const execa = require('execa'); -const version = require('./version'); -const git = require('./git-util'); -const npm = require('./npm/util'); -const {getTagVersionPrefix} = require('./util'); +import process from 'node:process'; +import Listr from 'listr'; +import {execa} from 'execa'; +import semver from 'semver'; +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 {getOidcProvider} from './npm/oidc.js'; -module.exports = (input, pkg, options) => { - const isExternalRegistry = npm.isExternalRegistry(pkg); - let newVersion = null; +const prerequisiteTasks = (input, package_, options, {packageManager, rootDirectory}) => { + const isExternalRegistry = npm.isExternalRegistry(package_); + let newVersion; const tasks = [ { title: 'Ping npm registry', - enabled: () => !pkg.private && !isExternalRegistry, - task: async () => npm.checkConnection() + enabled: () => !package_.private && !isExternalRegistry, + task: async () => npm.checkConnection(), }, { - title: 'Check npm version', - task: async () => npm.verifyRecentNpmVersion() - }, - { - title: 'Check yarn version', - enabled: () => options.yarn === true, - task: async () => { - const {stdout: yarnVersion} = await execa('yarn', ['--version']); - version.verifyRequirementSatisfied('yarn', yarnVersion); - } + title: `Check ${packageManager.cli} version`, + async task() { + const {stdout: version} = await execa(packageManager.cli, ['--version']); + util.validateEngineVersionSatisfies(packageManager.cli, version); + }, }, { title: 'Verify user is authenticated', - enabled: () => process.env.NODE_ENV !== 'test' && !pkg.private, - task: async () => { - const username = await npm.username({ - externalRegistry: isExternalRegistry ? pkg.publishConfig.registry : false - }); + enabled: () => process.env.NODE_ENV !== 'test' && !package_.private, + skip() { + if (getOidcProvider()) { + return 'Environment support for OIDC authentication detected - Skipping whoami check'; + } + }, + async task() { + const externalRegistry = package_.publishConfig?.registry; + const username = await npm.username({externalRegistry}); - const collaborators = await npm.collaborators(pkg); + const collaborators = await npm.collaborators(package_); if (!collaborators) { return; } @@ -46,49 +47,95 @@ module.exports = (input, pkg, options) => { if (!permissions || !permissions.includes('write')) { throw new Error('You do not have write permissions required to publish this package.'); } - } + }, }, { title: 'Check git version', - task: async () => git.verifyRecentGitVersion() + task: async () => git.verifyRecentGitVersion(), + }, + { + title: 'Check git user configuration', + task: async () => git.verifyUserConfigIsSet(), }, { title: 'Check git remote', - task: async () => git.verifyRemoteIsValid() + task: async () => git.verifyRemoteIsValid(options.remote), }, { title: 'Validate version', - task: () => { - if (!version.isValidInput(input)) { - throw new Error(`Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); + task() { + newVersion = input instanceof Version + ? input + : new Version(package_.version).setFrom(input); + }, + }, + { + title: 'Check for pre-release version', + task() { + if (!package_.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'); + } + }, + }, + { + title: 'Check for Node.js engine support drop', + enabled: () => !options.yolo && !package_.private, + async task() { + const publishedEngines = await npm.getPublishedPackageEngines(package_); + + // Skip if this is the first publish or if published package has no engines.node + if (!publishedEngines?.node) { + return; } - newVersion = version(pkg.version).getNewVersionFrom(input); + const localNodeEngine = package_.engines?.node; - if (version(pkg.version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${pkg.version}\``); + // Skip if local package has no engines.node (we can't compare) + if (!localNodeEngine) { + return; } - } + + const publishedMinimum = util.getMinimumNodeVersion(publishedEngines.node); + const localMinimum = util.getMinimumNodeVersion(localNodeEngine); + + // Skip if we couldn't parse either version + if (!publishedMinimum || !localMinimum) { + return; + } + + // Check if the minimum Node.js version has increased + if (semver.gt(localMinimum, publishedMinimum)) { + const diff = semver.diff(package_.version, newVersion.toString()); + + // Only major and premajor releases are allowed to drop Node.js support + // For pre-1.0.0 packages, minor bumps are considered breaking changes per semver + if (diff !== 'major' && diff !== 'premajor' && semver.major(package_.version) >= 1) { + throw new Error(`Raising minimum Node.js version from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); + } + } + }, }, { - title: 'Check for pre-release version', - task: () => { - if (!pkg.private && version(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'); - } - } + title: 'Verify package entry points', + enabled: () => !options.yolo, + async task() { + await npm.verifyPackageEntryPoints(package_, rootDirectory); + }, }, { title: 'Check git tag existence', - task: async () => { + async task() { + // Fetch is needed so `tagExistsOnRemote` can check local refs. Don't replace with `ls-remote`. await git.fetch(); - const tagPrefix = await getTagVersionPrefix(options); + const tagPrefix = await util.getTagVersionPrefix(packageManager); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); - } - } + }, + }, ]; return new Listr(tasks); }; + +export default prerequisiteTasks; diff --git a/source/pretty-version-diff.js b/source/pretty-version-diff.js deleted file mode 100644 index 15eb60b8..00000000 --- a/source/pretty-version-diff.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; -const chalk = require('chalk'); -const version = require('./version'); - -module.exports = (oldVersion, inc) => { - const newVersion = version(oldVersion).getNewVersionFrom(inc).split('.'); - oldVersion = oldVersion.split('.'); - let firstVersionChange = false; - const output = []; - - for (const [i, element] of newVersion.entries()) { - if ((element !== oldVersion[i] && !firstVersionChange)) { - output.push(`${chalk.dim.cyan(element)}`); - firstVersionChange = true; - } else if (element.indexOf('-') >= 1) { - let preVersion = []; - preVersion = element.split('-'); - output.push(`${chalk.dim.cyan(`${preVersion[0]}-${preVersion[1]}`)}`); - } else { - output.push(chalk.reset.dim(element)); - } - } - - return output.join(chalk.reset.dim('.')); -}; diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 53d93d6f..02b49ae1 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -1,23 +1,47 @@ -'use strict'; -const open = require('open'); -const newGithubReleaseUrl = require('new-github-release-url'); -const {getTagVersionPrefix, getPreReleasePrefix} = require('./util'); -const version = require('./version'); - -module.exports = async (options, pkg) => { - const newVersion = version(pkg.version).getNewVersionFrom(options.version); - let tag = await getTagVersionPrefix(options) + newVersion; - const isPreRelease = version(options.version).isPrerelease(); - if (isPreRelease) { - tag += await getPreReleasePrefix(options); +import open from 'open'; +import newGithubReleaseUrl from 'new-github-release-url'; +import clipboard from 'clipboardy'; +import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; +import Version from './version.js'; + +// GitHub has a URL limit of ~8195 characters. We use a conservative limit to be safe. +const URL_LENGTH_LIMIT = 7900; +const CLIPBOARD_PLACEHOLDER = ''; + +const releaseTaskHelper = async (options, package_, packageManager) => { + if (!options.repoUrl) { + throw new Error('Missing `repository` field in package.json. This is required for creating GitHub releases.'); } - const url = newGithubReleaseUrl({ + const newVersion = options.releaseDraftOnly + ? new Version(package_.version) + : new Version(package_.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(packageManager)}); + + const tag = await getTagVersionPrefix(packageManager) + newVersion.toString(); + + const releaseNotes = options.releaseNotes ? options.generateReleaseNotes(tag) : ''; + + // Try to generate URL with full release notes + let url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, - body: options.releaseNotes(tag), - isPrerelease: isPreRelease + body: releaseNotes, + isPrerelease: newVersion.isPrerelease(), }); + // If the URL is too long, copy release notes to clipboard and use a placeholder + if (url.length > URL_LENGTH_LIMIT) { + await clipboard.write(releaseNotes); + url = newGithubReleaseUrl({ + repoUrl: options.repoUrl, + tag, + body: CLIPBOARD_PLACEHOLDER, + isPrerelease: newVersion.isPrerelease(), + }); + console.log('\nRelease notes are too long for URL. Copied to clipboard instead.'); + } + await open(url); }; + +export default releaseTaskHelper; diff --git a/source/ui.js b/source/ui.js index 6c82aa27..9aaa313f 100644 --- a/source/ui.js +++ b/source/ui.js @@ -1,202 +1,491 @@ -'use strict'; -const inquirer = require('inquirer'); -const chalk = require('chalk'); -const githubUrlFromGit = require('github-url-from-git'); -const {htmlEscape} = require('escape-goat'); -const isScoped = require('is-scoped'); -const util = require('./util'); -const git = require('./git-util'); -const {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} = require('./npm/util'); -const version = require('./version'); -const prettyVersionDiff = require('./pretty-version-diff'); - -const printCommitLog = async (repoUrl, registryUrl) => { - const latest = await git.latestTagOrFirstCommit(); - const log = await git.commitLogFromRevision(latest); +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import githubUrlFromGit from 'github-url-from-git'; +import hostedGitInfo from 'hosted-git-info'; +import {htmlEscape} from 'escape-goat'; +import isScoped from 'is-scoped'; +import isInteractive from 'is-interactive'; +import {execa} from 'execa'; +import semver from 'semver'; +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'; + +const PRERELEASE_INCREMENTS = new Set([ + 'prepatch', + 'preminor', + 'premajor', + 'prerelease', +]); + +const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => { + const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit(); + if (!revision) { + throw new Error('The package has not been published yet.'); + } + + const log = await git.commitLogFromRevision(revision); if (!log) { return { hasCommits: false, - releaseNotes: () => {} + hasUnreleasedCommits: false, + generateReleaseNotes() {}, }; } - const commits = log.split('\n') + let hasUnreleasedCommits = false; + let commitRangeText = `${revision}...${releaseBranch}`; + + let commits = log.split('\n') .map(commit => { const splitIndex = commit.lastIndexOf(' '); return { message: commit.slice(0, splitIndex), - id: commit.slice(splitIndex + 1) + id: commit.slice(splitIndex + 1), }; }); + if (!fromLatestTag) { + const latestTag = await git.latestTag(); + + // Version bump commit created by np, following the semver specification. + const versionBumpCommitName = latestTag.match(/v\d+\.\d+\.\d+/) && latestTag.slice(1); // Name v1.0.1 becomes 1.0.1 + const versionBumpCommitIndex = commits.findIndex(commit => commit.message === versionBumpCommitName); + + if (versionBumpCommitIndex > 0) { + commitRangeText = `${revision}...${latestTag}`; + hasUnreleasedCommits = true; + } + + if (await git.isHeadDetached()) { + commitRangeText = `${revision}...${latestTag}`; + } + + // Get rid of unreleased commits and of the version bump commit. + commits = commits.slice(versionBumpCommitIndex + 1); + } + const history = commits.map(commit => { const commitMessage = util.linkifyIssues(repoUrl, commit.message); const commitId = util.linkifyCommit(repoUrl, commit.id); return `- ${commitMessage} ${commitId}`; }).join('\n'); - const releaseNotes = nextTag => commits.map(commit => - `- ${htmlEscape(commit.message)} ${commit.id}` - ).join('\n') + `\n\n${repoUrl}/compare/${latest}...${nextTag}`; - - const commitRange = util.linkifyCommitRange(repoUrl, `${latest}...master`); + const generateReleaseNotes = nextTag => commits.map(commit => + `- ${htmlEscape(commit.message)} ${commit.id}`).join('\n') + `\n\n---\n\n${repoUrl}/compare/${revision}...${nextTag}`; + const commitRange = util.linkifyCommitRange(repoUrl, commitRangeText); console.log(`${chalk.bold('Commits:')}\n${history}\n\n${chalk.bold('Commit Range:')}\n${commitRange}\n\n${chalk.bold('Registry:')}\n${registryUrl}\n`); return { hasCommits: true, - releaseNotes + hasUnreleasedCommits, + generateReleaseNotes, + }; +}; + +const checkNewFilesAndDependencies = async (package_, rootDirectory) => { + const newFiles = await util.getNewFiles(rootDirectory); + const newDependencies = await util.getNewDependencies(package_, rootDirectory); + + const noNewFirstTimeFiles = !newFiles.firstTime || newFiles.firstTime.length === 0; + const noNewDependencies = !newDependencies || newDependencies.length === 0; + + // Only prompt for first-time files and new dependencies (things that WILL be published) + if (noNewFirstTimeFiles && noNewDependencies) { + return { + confirmed: true, + unpublishedFiles: newFiles.unpublished || [], + }; + } + + const messages = []; + if (newFiles.firstTime.length > 0) { + messages.push(`The following new files will be published for the first time:\n${util.joinList(newFiles.firstTime)}\n\nPlease make sure only the intended files are listed.`); + } + + if (newDependencies.length > 0) { + messages.push(`The following new dependencies will be part of your published package:\n${util.joinList(newDependencies)}\n\nPlease make sure these new dependencies are intentional.`); + } + + if (!isInteractive()) { + console.log(messages.join('\n')); + return { + confirmed: true, + unpublishedFiles: newFiles.unpublished || [], + }; + } + + const answers = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `${messages.join('\n')}\nContinue?`, + default: false, + }]); + + return { + confirmed: answers.confirm, + unpublishedFiles: newFiles.unpublished || [], }; }; -module.exports = async (options, pkg) => { - const oldVersion = pkg.version; +const displayUnpublishedFilesWarning = unpublishedFiles => { + if (!unpublishedFiles || unpublishedFiles.length === 0) { + return; + } + + console.log([ + '', + chalk.yellow('⚠ WARNING: The following new files will NOT be published:'), + chalk.dim(util.groupFilesInFolders(unpublishedFiles)), + '', + chalk.yellow('These files are excluded by your package.json "files" field.'), + chalk.yellow('If you intended to publish them, add them to the "files" field.'), + '', + ].join('\n')); +}; + +/** +@param {import('./cli-implementation.js').CLI['flags']} options +@param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context +*/ +const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { // eslint-disable-line complexity + const oldVersion = package_.version; const extraBaseUrls = ['gitlab.com']; - const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); - const pkgManager = options.yarn ? 'yarn' : 'npm'; - const registryUrl = await getRegistryUrl(pkgManager, pkg); + const repoUrl = package_.repository && (() => { + // Try to parse with hosted-git-info first to handle shorthand URLs like "github:foo/bar" + const gitInfo = hostedGitInfo.fromUrl(package_.repository.url); + if (gitInfo?.browse) { + return gitInfo.browse({noCommittish: true}); + } + + // Fall back to github-url-from-git for GitLab and other known hosts + const githubUrl = githubUrlFromGit(package_.repository.url, {extraBaseUrls}); + if (githubUrl) { + return githubUrl; + } + + // Final fallback: parse any git URL format (handles GitHub Enterprise and other hosts) + return util.parseGitUrl(package_.repository.url); + })(); + + const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand); + const releaseBranch = options.branch; + let unpublishedFiles; if (options.runPublish) { - checkIgnoreStrategy(pkg); - } - - console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); - - const prompts = [ - { - type: 'list', - name: 'version', - 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)}`, - value: inc - })) - .concat([ - new inquirer.Separator(), - { - name: 'Other (specify)', - value: null - } - ]), - filter: input => version.isValidInput(input) ? version(oldVersion).getNewVersionFrom(input) : input - }, - { - type: 'input', - name: 'customVersion', - message: 'Version', - when: answers => !answers.version, - filter: input => version.isValidInput(input) ? 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'; - } + await npm.checkIgnoreStrategy(package_, rootDirectory); - if (version(oldVersion).isLowerThanOrEqualTo(input)) { - return `Version must be greater than ${oldVersion}`; - } + const {confirmed, unpublishedFiles: files} = await checkNewFilesAndDependencies(package_, rootDirectory); + unpublishedFiles = files; + if (!confirmed) { + return { + ...options, + confirm: confirmed, + }; + } + } - return true; - } - }, - { - type: 'list', - name: 'tag', - 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, - choices: async () => { - const existingPrereleaseTags = await prereleaseTags(pkg.name); + if (options.releaseDraftOnly) { + console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(package_.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + } else { + const versionText = options.version + ? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion).setFrom(options.version, {prereleasePrefix: await util.getPreReleasePrefix(packageManager)}).format()})`) + : chalk.dim(`(current: ${oldVersion})`); - return [ - ...existingPrereleaseTags, - new inquirer.Separator(), - { - name: 'Other (specify)', - value: null - } - ]; - } - }, - { - type: 'input', - name: 'customTag', - message: 'Tag', - when: answers => options.runPublish && (version.isPrereleaseOrIncrement(answers.customVersion) || version.isPrereleaseOrIncrement(answers.version)) && !options.tag && !answers.tag, - validate: input => { - if (input.length === 0) { - return 'Please specify a tag, for example, `next`.'; - } + console.log(`\nPublish a new version of ${chalk.bold.magenta(package_.name)} ${versionText}\n`); + } - if (input.toLowerCase() === 'latest') { - return 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.'; - } + const useLatestTag = !options.releaseDraftOnly; + const {hasCommits, hasUnreleasedCommits, generateReleaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); - return true; - } - }, - { - type: 'confirm', - name: 'publishScoped', - when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !isExternalRegistry(pkg), - message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, - default: false + // Display unpublished files warning after commit log + displayUnpublishedFilesWarning(unpublishedFiles); + + if (hasUnreleasedCommits && options.releaseDraftOnly) { + const answers = await inquirer.prompt({ + confirm: { + type: 'confirm', + message: 'Unreleased commits found. They won\'t be included in the release draft. Continue?', + default: false, + }, + }); + + if (!answers.confirm) { + return { + ...options, + ...answers, + }; } - ]; + } - const {hasCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl); + if (options.releaseDraftOnly) { + return { + ...options, + confirm: true, + repoUrl, + generateReleaseNotes, + }; + } + // Non-interactive mode - return before prompting + // But if it's a prerelease without a tag, we need to prompt for the tag if (options.version) { + const prereleasePrefix = await util.getPreReleasePrefix(packageManager); + const versionObject = new Version(oldVersion).setFrom(options.version, {prereleasePrefix}); + const needsTag = options.runPublish && versionObject.isPrerelease() && !options.tag; + + if (!needsTag) { + return { + ...options, + confirm: true, + repoUrl, + generateReleaseNotes, + }; + } + + // Prompt for tag only + const answers = await inquirer.prompt({ + tag: { + type: 'select', + message: 'How should this pre-release version be tagged in npm?', + async choices() { + const existingPrereleaseTags = await npm.prereleaseTags(package_.name); + + return [ + ...existingPrereleaseTags, + new inquirer.Separator(), + { + name: 'Other (specify)', + value: undefined, + }, + ]; + }, + }, + customTag: { + type: 'input', + message: 'Tag', + when: answers => answers.tag === undefined, + validate(input) { + if (input.length === 0) { + return 'Please specify a tag, for example, `next`.'; + } + + if (input.toLowerCase() === 'latest') { + return 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.'; + } + + return true; + }, + }, + }); + return { ...options, + tag: answers.tag || answers.customTag || options.tag, confirm: true, repoUrl, - releaseNotes + generateReleaseNotes, }; } if (!hasCommits) { - const answers = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: 'No commits found since previous release, continue?', - default: false - }]); + const answers = await inquirer.prompt({ + confirm: { + type: 'confirm', + message: 'No commits found since previous release, continue?', + default: false, + }, + }); if (!answers.confirm) { return { ...options, - ...answers + ...answers, }; } } if (options.availability.isUnknown) { - const answers = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - when: isScoped(pkg.name) && options.runPublish, - message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(pkg.name)}. Do you want to try and publish it anyway?`, - default: false - }]); + if (!isScoped(package_.name)) { + throw new Error('Unknown availability, but package is not scoped. This shouldn\'t happen'); + } + + const answers = await inquirer.prompt({ + confirm: { + type: 'confirm', + when: isScoped(package_.name) && options.runPublish, + message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(package_.name)}. Do you want to try and publish it anyway?`, + default: false, + }, + }); if (!answers.confirm) { return { ...options, - ...answers + ...answers, }; } } - const answers = await inquirer.prompt(prompts); + const needsPrereleaseTag = answers => { + if (!options.runPublish || options.tag) { + return false; + } + + // Check if version is a prerelease increment + if (answers.version) { + return PRERELEASE_INCREMENTS.has(answers.version); + } + + // Check if custom version is a prerelease + return answers.customVersion?.isPrerelease(); + }; + + const alreadyPublicScoped = packageManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(package_) === 'public'; + + // Note that inquirer question.when is a bit confusing. Only `false` will cause the question to be skipped. + // Any other value like `true` and `undefined` means ask the question. + // so we make sure to always return an explicit boolean here to make it less confusing + // see https://github.com/SBoudrias/Inquirer.js/pull/1340 + const needToAskForPublish = (() => { + if (alreadyPublicScoped || !isScoped(package_.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { + return false; + } + + // Only ask if access is not explicitly set and not using an external registry + return !package_.publishConfig?.access && !npm.isExternalRegistry(package_); + })(); + + // Extract prerelease identifier from current version if it exists, otherwise use npm config + const currentPrerelease = semver.prerelease(oldVersion); + // Only use the prefix if it's a string (not a number like in '1.0.0-0') + const currentPrereleasePrefix = typeof currentPrerelease?.[0] === 'string' ? currentPrerelease[0] : undefined; + const configPrereleasePrefix = await util.getPreReleasePrefix(packageManager); + const defaultPrereleasePrefix = currentPrereleasePrefix ?? configPrereleasePrefix; + + const answers = await inquirer.prompt({ + version: { + type: 'select', + message: 'Select SemVer increment or specify new version', + pageSize: SEMVER_INCREMENTS.length + 2, + default: 0, + choices: [ + ...SEMVER_INCREMENTS.map(increment => ({ + name: `${increment} ${new Version(oldVersion, increment, {prereleasePrefix: defaultPrereleasePrefix}).format()}`, + value: increment, + })), + new inquirer.Separator(), + { + name: 'Other (specify)', + value: undefined, + }, + ], + }, + customVersion: { + type: 'input', + message: 'Version', + when: answers => answers.version === undefined, + 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; + }, + }, + prereleasePrefix: { + type: 'input', + message: 'Prerelease identifier', + default: defaultPrereleasePrefix, + when(answers) { + // Only ask when a prerelease increment was selected from the menu + if (!answers.version) { + return false; + } + + return PRERELEASE_INCREMENTS.has(answers.version); + }, + }, + tag: { + type: 'select', + message: 'How should this pre-release version be tagged in npm?', + when: answers => needsPrereleaseTag(answers), + async choices() { + const existingPrereleaseTags = await npm.prereleaseTags(package_.name); + + return [ + ...existingPrereleaseTags, + new inquirer.Separator(), + { + name: 'Other (specify)', + value: undefined, + }, + ]; + }, + }, + customTag: { + type: 'input', + message: 'Tag', + when: answers => answers.tag === undefined && needsPrereleaseTag(answers), + validate(input) { + if (input.length === 0) { + return 'Please specify a tag, for example, `next`.'; + } + + if (input.toLowerCase() === 'latest') { + return 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.'; + } + + return true; + }, + }, + publishScoped: { + type: 'confirm', + when: needToAskForPublish, + message: `This scoped repo ${chalk.bold.magenta(package_.name)} hasn't been published. Do you want to publish it publicly?`, + default: false, + }, + }); + + // Create Version object with custom prerelease prefix if provided + let version; + if (answers.version) { + // Use || not ?? to treat empty string as falsy (fall back to default/rc) + const prereleasePrefix = answers.prereleasePrefix || defaultPrereleasePrefix; + version = new Version(oldVersion, answers.version, {prereleasePrefix}); + } else if (answers.customVersion) { + version = answers.customVersion; + } else { + version = options.version; + } return { ...options, - version: answers.version || answers.customVersion || options.version, + version, tag: answers.tag || answers.customTag || options.tag, + publishScoped: alreadyPublicScoped || answers.publishScoped, confirm: true, repoUrl, - releaseNotes + generateReleaseNotes, }; }; + +export default ui; diff --git a/source/util.js b/source/util.js index c1762ea3..1879a4c9 100644 --- a/source/util.js +++ b/source/util.js @@ -1,28 +1,41 @@ -'use strict'; -const readPkgUp = require('read-pkg-up'); -const issueRegex = require('issue-regex'); -const terminalLink = require('terminal-link'); -const execa = require('execa'); -const pMemoize = require('p-memoize'); -const ow = require('ow'); -const pkgDir = require('pkg-dir'); - -exports.readPkg = packagePath => { - packagePath = packagePath ? pkgDir.sync(packagePath) : pkgDir.sync(); - - if (!packagePath) { - throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import {readPackageUp} from 'read-package-up'; +import {parsePackage} from 'read-pkg'; +import issueRegex from 'issue-regex'; +import terminalLink from 'terminal-link'; +import {execa} from 'execa'; +import pMemoize from 'p-memoize'; +import chalk from 'chalk'; +import semver from 'semver'; +import Version from './version.js'; +import * as git from './git-util.js'; +import * as npm from './npm/util.js'; + +export const assert = (condition, message) => { + if (!condition) { + throw new Error(message); } +}; - const {packageJson} = readPkgUp.sync({ - cwd: packagePath - }); +export const readPackage = async (packagePath = process.cwd()) => { + const packageResult = await readPackageUp({cwd: packagePath}); + + if (!packageResult) { + throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); + } - return packageJson; + return {package_: packageResult.packageJson, rootDirectory: path.dirname(packageResult.path)}; }; -exports.linkifyIssues = (url, message) => { - if (!(url && terminalLink.isSupported)) { +const _npRootDirectory = fileURLToPath(new URL('..', import.meta.url)); + +// Re-define `npRootDirectory` for trailing slash consistency. +export const {package_: npPackage, rootDirectory: npRootDirectory} = await readPackage(_npRootDirectory); + +export const linkifyIssues = (url, message) => { + if (!url) { return message; } @@ -37,58 +50,238 @@ exports.linkifyIssues = (url, message) => { }); }; -exports.linkifyCommit = (url, commit) => { - if (!(url && terminalLink.isSupported)) { +export const linkifyCommit = (url, commit) => { + if (!url) { return commit; } return terminalLink(commit, `${url}/commit/${commit}`); }; -exports.linkifyCommitRange = (url, commitRange) => { - if (!(url && terminalLink.isSupported)) { +export const linkifyCommitRange = (url, commitRange) => { + if (!url) { return commitRange; } return terminalLink(commitRange, `${url}/compare/${commitRange}`); }; -exports.getTagVersionPrefix = pMemoize(async options => { - ow(options, ow.object.hasKeys('yarn')); +/* +Git URL patterns for parsing various formats. - try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'version-tag-prefix']); - return stdout; +Patterns use greedy matching + cleanRepo logic to handle edge cases like: +- URLs with double .git suffix (repo.git.git) +- Repos with .git in their name (my.git.git where repo is my.git) + +Using [^\s/?#] to exclude whitespace, query params (?), and fragments (#) +Query params and fragments are stripped before matching. +*/ +const GIT_URL_PATTERNS = [ + /// https://host/owner/repo.git or https://host/owner/repo + // Case-insensitive protocol matching via /i flag + { + regex: /^https?:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// git@host:owner/repo.git (common SSH format) + // Using [^\s:?#] and [^\s/?#] creates clear boundaries + { + regex: /^git@([^\s:?#]+):([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// git+https://host/owner/repo.git + { + regex: /^git\+https:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// ssh://git@host/owner/repo.git + { + regex: /^ssh:\/\/git@([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, +]; + +const ALPHANUMERIC_REGEX = /[a-z\d]/i; +const isValidGitPathComponent = value => Boolean(value) && ALPHANUMERIC_REGEX.test(value); + +/** +Parse a git URL to extract the HTTPS browse URL. + +Handles various git URL formats including GitHub Enterprise. + +This function uses carefully crafted regex patterns that avoid ReDoS vulnerabilities: +- All patterns are anchored with ^ and $ to prevent partial matches +- Character classes use negated sets [^...] which are linear-time +- No nested quantifiers or overlapping alternatives +- Greedy quantifiers with explicit bounds prevent exponential backtracking + +@param {string} url - The git URL to parse. +@returns {string | undefined} - The HTTPS browse URL or undefined if parsing fails. + +@example +``` +parseGitUrl('git@github.com:owner/repo.git'); +//=> 'https://github.com/owner/repo' + +parseGitUrl('https://github.com/owner/repo.git'); +//=> 'https://github.com/owner/repo' + +parseGitUrl('github:owner/repo'); +//=> undefined (use hosted-git-info for this) +``` +*/ +export const parseGitUrl = url => { + if (typeof url !== 'string' || url.length === 0) { + return; + } + + const cleanUrl = url.split(/[?#]/, 1)[0]; + if (cleanUrl.length === 0) { + return; + } + + for (const {regex, transform} of GIT_URL_PATTERNS) { + const match = cleanUrl.match(regex); + if (match) { + const [, host, owner, repo] = match; + + // Remove .git suffix if present in the captured repo name + const cleanRepo = repo.endsWith('.git') ? repo.slice(0, -4) : repo; + + // Validate that none of the components are empty + if (!host) { + continue; + } + + // Validate that owner and repo contain at least one alphanumeric character + // This prevents pathological inputs like all dots or special chars + if (!isValidGitPathComponent(owner) || !isValidGitPathComponent(cleanRepo)) { + continue; + } + + return transform(host, owner, cleanRepo); } + } +}; + +/** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ +export const getTagVersionPrefix = pMemoize(async config => { + assert(config && Object.hasOwn(config, 'tagVersionPrefixCommand'), 'Config is missing key `tagVersionPrefixCommand`'); + + try { + const {stdout} = await execa(...config.tagVersionPrefixCommand); - const {stdout} = await execa('npm', ['config', 'get', 'tag-version-prefix']); return stdout; - } catch (_) { + } catch { return 'v'; } }); -exports.getPreReleasePrefix = pMemoize(async options => { - ow(options, ow.object.hasKeys('yarn')); +export const joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); - try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } +export const groupFilesInFolders = (files, groupingMinimumDepth = 1, groupingThresholdCount = 5) => { + const groups = {}; + for (const file of files) { + const groupKey = path.join(...file.split(path.sep).slice(0, groupingMinimumDepth)); + groups[groupKey] = [...groups[groupKey] ?? [], file]; + } - return ''; + const lines = []; + for (const [folder, filesInFolder] of Object.entries(groups)) { + if (filesInFolder.length > groupingThresholdCount) { + lines.push(`- ${folder}/* ${chalk.bold.white(`(${filesInFolder.length} files)`)}`); + continue; } - const {stdout} = await execa('npm', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; + for (const file of filesInFolder) { + lines.push(`- ${file}`); } + } - return ''; - } catch (_) { + return chalk.reset(lines.join('\n')); +}; + +export const getNewFiles = async rootDirectory => { + const listNewFiles = await git.newFilesSinceLastRelease(rootDirectory); + const listPackageFiles = await npm.getFilesToBePacked(rootDirectory); + + return { + unpublished: listNewFiles.filter(file => !listPackageFiles.includes(file) && !file.startsWith('.git')), + firstTime: listNewFiles.filter(file => listPackageFiles.includes(file)), + }; +}; + +export const getNewDependencies = async (newPackage, rootDirectory) => { + let oldPackageFile; + + try { + oldPackageFile = await git.readFileFromLastRelease(path.resolve(rootDirectory, 'package.json')); + } catch { + // Handle first time publish + return Object.keys(newPackage.dependencies ?? {}); + } + + const oldPackage = parsePackage(oldPackageFile); + + const newDependencies = []; + + for (const dependency of Object.keys(newPackage.dependencies ?? {})) { + if (!oldPackage.dependencies?.[dependency]) { + newDependencies.push(dependency); + } + } + + return newDependencies; +}; + +/** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ +export const getPreReleasePrefix = pMemoize(async config => { + assert(config && Object.hasOwn(config, 'cli'), 'Config is missing key `cli`'); + + try { + const {stdout} = await execa(config.cli, ['config', 'get', 'preid']); + + return stdout === 'undefined' ? '' : stdout; + } catch { return ''; } }); + +export const validateEngineVersionSatisfies = (engine, version) => { + const engineRange = npPackage.engines[engine]; + if (!new Version(version).satisfies(engineRange)) { + throw new Error(`\`np\` requires ${engine} ${engineRange}`); + } +}; + +export async function getNpmPackageAccess(package_) { + const arguments_ = ['access', 'get', 'status', package_.name, '--json']; + + if (package_.publishConfig?.registry) { + arguments_.push('--registry', package_.publishConfig.registry); + } + + try { + const {stdout} = await execa('npm', arguments_, {timeout: npm.npmNetworkTimeout}); + return JSON.parse(stdout)[package_.name]; // Note: returns "private" for non-existent packages + } catch (error) { + if (error.timedOut) { + error.message = 'Connection to npm registry timed out'; + } + + throw error; + } +} + +export const getMinimumNodeVersion = range => { + if (!range || typeof range !== 'string') { + return undefined; + } + + try { + const minVersion = semver.minVersion(range); + return minVersion?.version; + } catch { + return undefined; + } +}; diff --git a/source/version.js b/source/version.js index c12d5425..812698d3 100644 --- a/source/version.js +++ b/source/version.js @@ -1,66 +1,194 @@ -'use strict'; -const semver = require('semver'); +import semver from 'semver'; +import {template as chalk} from 'chalk-template'; -class Version { - constructor(version) { - this.version = version; +/** @type {string[]} Allowed `SemVer` release types. */ +export const SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; +export const SEMVER_INCREMENTS_LIST = SEMVER_INCREMENTS.join(', '); +const SEMVER_INCREMENTS_LIST_LAST_OR = `${SEMVER_INCREMENTS.slice(0, -1).join(', ')}, or ${SEMVER_INCREMENTS.slice(-1)}`; + +/** @typedef {semver.SemVer} SemVerInstance */ +/** @typedef {semver.ReleaseType} SemVerIncrement */ +/** @typedef {import('chalk').ColorName | import('chalk').ModifierName} ColorName */ + +/** @param {string} input @returns {input is SemVerIncrement} */ +const isSemVersionIncrement = input => SEMVER_INCREMENTS.includes(input); + +/** @param {string} input */ +const isInvalidSemVersion = input => Boolean(!semver.valid(input)); + +/** +Formats the first difference between two versions to the given `diffColor`. Useful for `prerelease` diffs. + +@param {string[]} current @param {string[]} previous @param {ColorName} diffColor +*/ +const formatFirstDifference = (current, previous, diffColor) => { + const firstDifferenceIndex = current.findIndex((part, i) => previous.at(i) !== part); + current[firstDifferenceIndex] = `{${diffColor} ${current.at(firstDifferenceIndex)}}`; + return current.join('.'); +}; + +export default class Version { + /** @type {SemVerInstance} */ + #version; + /** @type {SemVerIncrement | undefined} */ + #diff = undefined; + /** @type {string | undefined} */ + #prereleasePrefix = undefined; + + toString() { + return this.#version.version; } - isPrerelease() { - return Boolean(semver.prerelease(this.version)); + /** + Sets `this.#version` to the given version. + + @param {string} version + @throws If `version` is an invalid `SemVer` version. + */ + #trySetVersion(version) { + this.#version = semver.parse(version); + + if (this.#version === null) { + throw new Error(`Version ${version} should be a valid SemVer version.`); + } } - satisfies(range) { - module.exports.validate(this.version); - return semver.satisfies(this.version, range, { - includePrerelease: true - }); + /** + @param {string} version - A valid `SemVer` version. + @param {SemVerIncrement} [increment] - Optionally increment `version`. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + */ + constructor(version, increment, {prereleasePrefix} = {}) { + this.#prereleasePrefix = prereleasePrefix; + this.#trySetVersion(version); + + if (increment) { + if (!isSemVersionIncrement(increment)) { + throw new Error(`Increment ${increment} should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); + } + + this.setFrom(increment); + } } - getNewVersionFrom(input) { - module.exports.validate(this.version); - if (!module.exports.isValidInput(input)) { - throw new Error(`Version should be either ${module.exports.SEMVER_INCREMENTS.join(', ')} or a valid semver version.`); + /** + Sets a new version based on `input`. If `input` is a valid `SemVer` increment, the current version will be incremented by that amount. If `input` is a valid `SemVer` version, the current version will be set to `input` if it is greater than the current version. + + @param {string | SemVerIncrement} input - A new valid `SemVer` version or a `SemVer` increment to increase the current version by. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + @throws If `input` is not a valid `SemVer` version or increment, or if `input` is a valid `SemVer` version but is not greater than the current version. + */ + setFrom(input, {prereleasePrefix = ''} = {}) { + this.#prereleasePrefix ??= prereleasePrefix; + const previousVersion = this.toString(); + + if (isSemVersionIncrement(input)) { + this.#version.inc(input, this.#prereleasePrefix); + } else { + if (isInvalidSemVersion(input)) { + throw new Error(`New version ${input} should either be one of ${SEMVER_INCREMENTS_LIST}, or a valid SemVer version.`); + } + + if (this.#isGreaterThanOrEqualTo(input)) { + throw new Error(`New version ${input} should be higher than current version ${this.toString()}.`); + } + + this.#trySetVersion(input); } - return module.exports.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; + // Set `this.#diff` to format version diffs + this.#diff = semver.diff(previousVersion, this.#version); + return this; } - isGreaterThanOrEqualTo(otherVersion) { - module.exports.validate(this.version); - module.exports.validate(otherVersion); + /** + Formats the current version with `options.color`, pretty-printing the version's diff with `options.diffColor` if possible. - return semver.gte(otherVersion, this.version); - } + If the current version has never been changed, providing `options.previousVersion` will allow pretty-printing the diff. It must be provided to format diffs between `prerelease` versions. - isLowerThanOrEqualTo(otherVersion) { - module.exports.validate(this.version); - module.exports.validate(otherVersion); + @param {object} options + @param {ColorName} [options.color = 'dim'] + @param {ColorName} [options.diffColor = 'cyan'] + @param {string} [options.prereleasePrefix] + @returns {string} A color-formatted version string. + */ + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { + if (typeof previousVersion === 'string') { + const previousSemver = semver.parse(previousVersion); - return semver.lte(otherVersion, this.version); - } -} + if (previousSemver === null) { + throw new Error(`Previous version ${previousVersion} should be a valid SemVer version.`); + } -module.exports = version => new Version(version); + previousVersion = previousSemver; + } -module.exports.SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; -module.exports.PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; + if (!this.#diff) { + if (!previousVersion) { + return chalk(`{${color} ${this.toString()}}`); + } -module.exports.isPrereleaseOrIncrement = input => module.exports(input).isPrerelease() || module.exports.PRERELEASE_VERSIONS.includes(input); + this.#diff = semver.diff(previousVersion, this.#version); + } -const isValidVersion = input => Boolean(semver.valid(input)); + const {major, minor, patch, prerelease} = this.#version; + const previousPrerelease = semver.prerelease(previousVersion); -module.exports.isValidInput = input => module.exports.SEMVER_INCREMENTS.includes(input) || isValidVersion(input); + if (prerelease && previousPrerelease) { + const prereleaseDiff = formatFirstDifference(prerelease, previousPrerelease, diffColor); + return chalk(`{${color} ${major}.${minor}.${patch}-${prereleaseDiff}}`); + } -module.exports.validate = version => { - if (!isValidVersion(version)) { - throw new Error('Version should be a valid semver version.'); + /* eslint-disable unicorn/no-nested-ternary */ + return ( + this.#diff === 'major' + ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}}`) + : this.#diff === 'minor' + ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}}`) + : this.#diff === 'patch' + ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}}`) + : this.#diff === 'premajor' + ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'preminor' + ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'prepatch' + ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' + ); + /* eslint-enable unicorn/no-nested-ternary */ } -}; -module.exports.verifyRequirementSatisfied = (dependency, version) => { - const depRange = require('../package.json').engines[dependency]; - if (!module.exports(version).satisfies(depRange)) { - throw new Error(`Please upgrade to ${dependency}${depRange}`); + /** + 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, { + 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/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts new file mode 100644 index 00000000..2dc778fc --- /dev/null +++ b/test/_helpers/integration-test.d.ts @@ -0,0 +1,32 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {Execa$} from 'execa'; + +type Context = { + firstCommitMessage: string; + getCommitMessage: (sha: string) => Promise; + createFile: (file: string, content?: string) => Promise; + commitNewFile: () => Promise<{ + sha: string; + commitMessage: string; + }>; +}; + +type CommandsFunctionParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDirectory: string; +}]; + +type AssertionsFunctionParameters = [{ + t: ExecutionContext; + testedModule: MockType; + $$: Execa$; + temporaryDirectory: string; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: (...arguments_: CommandsFunctionParameters) => Promise, + assertions: (...arguments_: AssertionsFunctionParameters) => Promise, +], Context>; + +export function _createFixture(source: string): CreateFixtureMacro; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js new file mode 100644 index 00000000..247f3405 --- /dev/null +++ b/test/_helpers/integration-test.js @@ -0,0 +1,68 @@ +/* eslint-disable ava/no-ignored-test-files */ +import crypto from 'node:crypto'; +import path from 'node:path'; +import fs from 'fs-extra'; +import test from 'ava'; +import esmock from 'esmock'; +import {$, execa} from 'execa'; +import {temporaryDirectoryTask} from 'tempy'; + +const createEmptyGitRepo = async ($$, temporaryDirectory) => { + const firstCommitMessage = '"init1"'; + + await $$`git init`; + + // `git tag` needs an initial commit + await fs.createFile(path.resolve(temporaryDirectory, 'temp')); + await $$`git add temp`; + await $$`git commit -m ${firstCommitMessage}`; + await $$`git rm temp`; + await $$`git commit -m "init2"`; + + return firstCommitMessage; +}; + +export const createIntegrationTest = async (t, assertions) => { + await temporaryDirectoryTask(async temporaryDirectory => { + const $$ = $({cwd: temporaryDirectory}); + + t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDirectory); + + // 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(temporaryDirectory, 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({$$, temporaryDirectory}); + }); +}; + +export const _createFixture = source => test.macro(async (t, commands, assertions) => { + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { + const testedModule = await esmock(source, {}, { + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (command, commandArguments, options = {}) => execa(command, commandArguments, {...options, cwd: temporaryDirectory})}, + }); + + await commands({t, $$, temporaryDirectory}); + await assertions({ + t, testedModule, $$, temporaryDirectory, + }); + }); +}); diff --git a/test/fixtures/listr-renderer.js b/test/_helpers/listr-renderer.js similarity index 60% rename from test/fixtures/listr-renderer.js rename to test/_helpers/listr-renderer.js index ee5982e6..9a9f2581 100644 --- a/test/fixtures/listr-renderer.js +++ b/test/_helpers/listr-renderer.js @@ -1,6 +1,6 @@ let tasks; -class SilentRenderer { +export class SilentRenderer { constructor(_tasks) { tasks = _tasks; } @@ -13,9 +13,11 @@ class SilentRenderer { return true; } - render() { } + static clearTasks() { + tasks = []; + } - end() { } -} + render() {} -module.exports.SilentRenderer = SilentRenderer; + end() {} +} diff --git a/test/_helpers/listr.js b/test/_helpers/listr.js new file mode 100644 index 00000000..f97b8297 --- /dev/null +++ b/test/_helpers/listr.js @@ -0,0 +1,25 @@ +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!`); +}; + +export const assertTaskSkipped = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(task.isSkipped(), `Task '${taskTitle}' was not skipped!`); +}; diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js new file mode 100644 index 00000000..18d87635 --- /dev/null +++ b/test/_helpers/mock-inquirer.js @@ -0,0 +1,231 @@ +import esmock from 'esmock'; +import is from '@sindresorhus/is'; +import stripAnsi from 'strip-ansi'; +import mapObject from 'map-obj'; + +/** @typedef {import('ava').ExecutionContext>} ExecutionContext */ +/** @typedef {string | boolean} ShortAnswer */ +/** @typedef {Record<'input' | 'error', string> | Record<'choice', string> | Record<'confirm', boolean>} LongAnswer */ +/** @typedef {ShortAnswer | LongAnswer} Answer */ +/** @typedef {Record} Answers */ +/** @typedef {import('inquirer').DistinctQuestion & {name?: never}} Prompt */ + +/** +Mocks `inquirer.prompt` and answers each prompt in the program with the provided `inputAnswers`. + +This only handles prompts of type `input`, `list`, and `confirm`. If other prompt types are added, they must be implemented here. + +Logs for debugging are outputted on test failure. + +@see https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 + +@param {object} o Test input and actual prompts +@param {ExecutionContext} o.t +@param {Answers} o.inputAnswers Test input +@param {Record | Prompt[]} o.prompts Actual prompts +*/ +const getPromptsObject = prompts => { + if (Array.isArray(prompts)) { + const promptsObject = {}; + + for (const prompt of prompts) { + promptsObject[prompt.name] = prompt; + } + + return promptsObject; + } + + return prompts; +}; + +const mockPrompt = async ({t, inputAnswers, prompts}) => { + const answers = {}; + prompts = getPromptsObject(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) { + t.log(`skipping prompt '${name}'`); + continue; + } + + if (is.function(prompt.when) && !prompt.when(answers)) { + t.log(`skipping prompt '${name}'`); + continue; + } + } + + t.log(`getting input for prompt '${name}'`); + + const setValue = value => { + if (prompt.validate) { + const result = prompt.validate(value); + + if (result !== true) { + if (is.string(result)) { + throw new Error(result); + } + + if (result === false) { + throw new Error('You must provide a valid value'); + } + } + } + + if (is.string(value)) { + t.log(`filtering value '${value}' for prompt '${name}'`); + } else { + t.log(`filtering value for prompt '${name}':`, value); + } + + answers[name] = prompt.filter + ? prompt.filter(value) // eslint-disable-line unicorn/no-array-callback-reference + : value; + + t.log(`got value '${answers[name]}' for prompt '${name}'`); + }; + + /** @param {Answer} input */ + const chooseValue = async input => { + t.is(prompt.type, 'select'); + let choices; + + if (is.asyncFunction(prompt.choices)) { + choices = await prompt.choices(answers); + } else if (is.function(prompt.choices)) { + choices = prompt.choices(answers); + } else { + choices = prompt.choices; + } + + t.log(`choices for prompt '${name}':`, choices); + + const value = choices.find(choice => { + if (is.object(choice)) { + return choice.name && stripAnsi(choice.name).startsWith(input.choice ?? input); + } + + if (is.string(choice)) { + return stripAnsi(choice).startsWith(input.choice ?? input); + } + + return false; + }); + + // `value.value` could exist but literally be `undefined` + setValue(Object.hasOwn(value, 'value') ? value.value : value); + }; + + const input = inputAnswers[name]; + + if (is.undefined(input)) { + t.fail(`Expected input for prompt '${name}'.`); + continue; + } + + if (is.string(input)) { + t.log(`found input for prompt '${name}': '${input}'`); + } else { + t.log(`found input for prompt '${name}':`, input); + } + + /** @param {Answer} input */ + const handleInput = async input => { + if (is.string(input)) { + if (['input'].includes(prompt.type)) { + setValue(input); + } else if (['select'].includes(prompt.type)) { + return chooseValue(input); + } else { + t.fail('Incorrect input type'); + } + + return; + } + + if (input.input !== undefined) { + t.is(prompt.type, 'input'); + setValue(input.input); + return; + } + + if (input.choice !== undefined) { + await chooseValue(input); + return; + } + + if (is.boolean(input.confirm) || is.boolean(input)) { + t.is(prompt.type, 'confirm'); + setValue(input.confirm ?? input); + } + }; + + // Multiple inputs for the given prompt + if (is.array(input)) { + for (const attempt of input) { + if (attempt.error) { + await t.throwsAsync( + handleInput(attempt), + {message: attempt.error}, + ); + } else { + await handleInput(attempt); + } + } + } + + await handleInput(input); + } + /* eslint-enable no-await-in-loop */ + + return answers; +}; + +/** +Fixes relative module paths for use with `esmock`. Allows specifiying the same relative location in test files as in source files. +@param {import('esmock').MockMap} mocks +*/ +const fixRelativeMocks = mocks => mapObject(mocks, (key, value) => [key.replace('./', '../../source/'), value]); + +/** +Mocks `inquirer` for testing `source/ui.js`. + +@param {object} o Test input and optional global mocks +@param {ExecutionContext} o.t +@param {Answers} o.answers Test input +@param {import('esmock').MockMap} [o.mocks] Optional global mocks +@param {(prompts: Record) => void} [o.onPrompt] Optional hook to inspect prompts +*/ +export const mockInquirer = async ({t, answers, mocks = {}, onPrompt = () => {}}) => { + /** @type {string[]} */ + const logs = []; + + /** @type {import('../../source/ui.js')} */ + const ui = await esmock('../../source/ui.js', import.meta.url, { + inquirer: { + async prompt(prompts) { + const promptDescriptors = getPromptsObject(prompts); + onPrompt(promptDescriptors); + + let uiAnswers = {}; + + const assertions = await t.try(async tt => { + uiAnswers = await mockPrompt({t: tt, inputAnswers: answers, prompts: promptDescriptors}); + }); + + assertions.commit({retainLogs: !assertions.passed}); + return uiAnswers; + }, + }, + }, { + ...fixRelativeMocks(mocks), + import: { + console: {log: (...arguments_) => logs.push(...arguments_)}, + }, + }); + + return {ui, logs}; +}; diff --git a/test/_helpers/stub-execa.d.ts b/test/_helpers/stub-execa.d.ts new file mode 100644 index 00000000..0318a4ae --- /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 AssertionsFunctionParameters = [{ + t: ExecutionContext; + testedModule: MockType; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: ExecaReturnValue[], + assertions: (...arguments_: AssertionsFunctionParameters) => 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..0ba70021 --- /dev/null +++ b/test/_helpers/stub-execa.js @@ -0,0 +1,99 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import {execa} from 'execa'; + +// Default stubs for common commands that should pass by default +const defaultCommands = [ + {command: 'npm --version', stdout: '10.0.0'}, + {command: 'npm ping', stdout: ''}, + {command: 'npm view --json test engines', stdout: ''}, + {command: 'git version', stdout: 'git version 2.40.0'}, + {command: 'git ls-remote origin HEAD', stdout: 'abc123\tHEAD'}, + {command: 'git fetch', stdout: ''}, + {command: 'git config --get tag.gpgSign', stdout: ''}, +]; + +/** +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 normalizedCommands = [...defaultCommands, ...commands].map(result => { + const [command, ...commandArguments] = result.command.split(' '); + return { + ...result, + command, + commandArguments, + }; + }); + + return sinon.stub().callsFake((command, commandArguments = [], options) => { + for (let index = normalizedCommands.length - 1; index >= 0; index--) { + const result = normalizedCommands[index]; + + if (result.command !== command) { + continue; + } + + if (!areArgumentsEqual(result.commandArguments, commandArguments)) { + continue; + } + + if (!matchesOptions(result.options, options)) { + continue; + } + + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); + + if (passes) { + return Promise.resolve(result); + } + + return Promise.reject(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + } + }); +}; + +const areArgumentsEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]); + +const matchesOptions = (expectedOptions, actualOptions) => { + if (!expectedOptions) { + return true; + } + + if (!actualOptions) { + return false; + } + + return Object.entries(expectedOptions).every(([key, value]) => Object.is(actualOptions[key], value)); +}; + +const stubExeca = commands => { + const execaStub = makeExecaStub(commands); + + return { + execa: { + async execa(...arguments_) { + // Only call real execa if stub doesn't have a match + const result = execaStub(...arguments_); + if (result === undefined) { + return execa(...arguments_); + } + + return result; + }, + }, + }; +}; + +export const _createFixture = (source, importMeta) => test.macro(async (t, commands, assertions) => { + const testedModule = await esmock(source, importMeta, {}, stubExeca(commands)); + await assertions({t, testedModule}); +}); diff --git a/test/_helpers/util.js b/test/_helpers/util.js new file mode 100644 index 00000000..b1f2c6dd --- /dev/null +++ b/test/_helpers/util.js @@ -0,0 +1,12 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +export const runIfExists = async (function_, ...arguments_) => { + if (typeof function_ === 'function') { + await function_(...arguments_); + } +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const getFixture = fixture => path.resolve(__dirname, '..', 'fixtures', ...fixture.split('/')); diff --git a/test/_helpers/verify-cli.d.ts b/test/_helpers/verify-cli.d.ts new file mode 100644 index 00000000..8b695ddc --- /dev/null +++ b/test/_helpers/verify-cli.d.ts @@ -0,0 +1,10 @@ +import type {Macro, ExecutionContext} from 'ava'; + +type VerifyCliMacro = Macro<[ + binPath: string, + args: string | string[], + expectedLines: string[], +], Record>; + +export const cliPasses: VerifyCliMacro; +export const cliFails: VerifyCliMacro; diff --git a/test/_helpers/verify-cli.js b/test/_helpers/verify-cli.js new file mode 100644 index 00000000..ebbed6b8 --- /dev/null +++ b/test/_helpers/verify-cli.js @@ -0,0 +1,16 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import {execa} from 'execa'; + +const trim = stdout => stdout.split('\n').map(line => line.trim()); + +const _verifyCli = shouldPass => test.macro(async (t, binaryPath, arguments_, expectedLines) => { + const {exitCode, stdout} = await execa(binaryPath, [arguments_].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..1893e66d --- /dev/null +++ b/test/cli.js @@ -0,0 +1,171 @@ +import path from 'node:path'; +import process from 'node:process'; +import test from 'ava'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import {execa} from 'execa'; +import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js'; +import {cliPasses} from './_helpers/verify-cli.js'; + +const cli = path.resolve(rootDirectory, 'source', 'cli-implementation.js'); + +test('flags: --help', cliPasses, cli, '--help', [ + '', + 'A better `npm publish`', + '', + 'Usage', + '$ np ', + '', + 'Version can be:', + 'patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3', + '', + 'Options', + '--any-branch Allow publishing from any branch', + '--branch Name of the release branch (default: main | master)', + '--no-cleanup Skips np\'s node_modules cleanup step before install', + '--no-tests Skips tests', + '--yolo Skips cleanup and testing', + '--no-publish Skips publishing', + '--dry-run Show tasks without actually executing them', + '--tag Publish under a given dist-tag', + '--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', + '--no-release-notes Skips generating release notes when opening a GitHub release draft', + '--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)', + '--package-manager Use a specific package manager (default: package.json packageManager/devEngines)', + '--provenance Publish with npm provenance statements (CI-only)', + '--remote Git remote to push to (default: origin)', + '', + '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', [npPackage.version]); + +test('flags: --dry-run is shown in help', async t => { + const {stdout} = await execa(cli, ['--help']); + + t.true(stdout.includes('--dry-run Show tasks without actually executing them')); +}); + +test('flags: unknown flags fail', async t => { + const {exitCode, stderr} = await execa(cli, ['--wat'], {reject: false}); + + t.is(exitCode, 2); + t.true(stderr.includes('Unknown flag')); + t.true(stderr.includes('--wat')); +}); + +test('flags: --preview remains an alias for --dry-run', async t => { + const {exitCode, stderr} = await execa(cli, ['--preview', '--wat'], {reject: false}); + + t.is(exitCode, 2); + t.true(stderr.includes('Unknown flag')); + t.true(stderr.includes('--wat')); + t.false(stderr.includes('--preview')); +}); + +const loadCliImplementation = async overrides => esmock('../source/cli-implementation.js', {}, { + meow: { + default: sinon.stub().returns({ + input: ['patch'], + flags: { + publish: false, + }, + pkg: npPackage, + }), + }, + 'update-notifier': {default: sinon.stub().returns({notify: sinon.stub()})}, + '../source/config.js': {default: sinon.stub().resolves({})}, + '../source/util.js': { + readPackage: sinon.stub().resolves({ + package_: { + name: 'test-package', + version: '1.0.0', + }, + rootDirectory: process.cwd(), + }), + }, + '../source/git-util.js': { + defaultBranch: sinon.stub().resolves('main'), + }, + '../source/git-tasks.js': { + verifyGitTasks: sinon.stub().resolves(), + }, + '../source/package-manager/index.js': { + getPackageManagerConfig: sinon.stub().returns({ + id: 'npm', + cli: 'npm', + }), + }, + '../source/npm/util.js': { + isExternalRegistry: sinon.stub().returns(false), + isPackageNameAvailable: sinon.stub(), + username: sinon.stub(), + login: sinon.stub(), + }, + '../source/npm/oidc.js': { + getOidcProvider: sinon.stub().returns(undefined), + }, + '../source/ui.js': {default: sinon.stub().callsFake(async options => ({...options, confirm: false, version: '1.0.1'}))}, + '../source/index.js': {default: sinon.stub().resolves({name: 'test-package', version: '1.0.1'})}, + 'exit-hook': { + gracefulExit: sinon.stub(), + }, + ...overrides, +}); + +test.serial('cli runs git preflight before prompting', async t => { + const verifyGitTasksStub = sinon.stub().rejects(new Error('Not on `main` branch.')); + const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'})); + const gracefulExitStub = sinon.stub(); + const consoleErrorStub = sinon.stub(console, 'error'); + + await loadCliImplementation({ + '../source/git-tasks.js': { + verifyGitTasks: verifyGitTasksStub, + }, + '../source/ui.js': {default: uiStub}, + 'exit-hook': { + gracefulExit: gracefulExitStub, + }, + }); + + t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(uiStub.notCalled); + t.true(gracefulExitStub.calledOnceWithExactly(1)); + + consoleErrorStub.restore(); +}); + +test.serial('cli continues to the publish flow after successful git preflight', async t => { + const verifyGitTasksStub = sinon.stub().resolves(); + const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'})); + const npStub = sinon.stub().resolves({name: 'test-package', version: '1.0.1'}); + const gracefulExitStub = sinon.stub(); + + await loadCliImplementation({ + '../source/git-tasks.js': { + verifyGitTasks: verifyGitTasksStub, + }, + '../source/ui.js': {default: uiStub}, + '../source/index.js': {default: npStub}, + 'exit-hook': { + gracefulExit: gracefulExitStub, + }, + }); + + t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(uiStub.calledOnce); + t.true(npStub.calledOnce); + t.false('skipGitTasks' in npStub.firstCall.args[1]); + t.true(gracefulExitStub.notCalled); +}); diff --git a/test/config.js b/test/config.js index ae02024f..01ee1502 100644 --- a/test/config.js +++ b/test/config.js @@ -1,76 +1,169 @@ -import path from 'path'; +import path from 'node:path'; import test from 'ava'; -import sinon from 'sinon'; -import proxyquire from 'proxyquire'; - -const fixtureBasePath = path.resolve('test', 'fixtures', 'config'); - -const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { - const pathsPkgDir = [path.resolve(fixtureBasePath, 'pkg-dir'), - path.resolve(fixtureBasePath, 'local1'), - path.resolve(fixtureBasePath, 'local2')]; - - const promises = []; - pathsPkgDir.forEach(pathPkgDir => { - promises.push(proxyquire('../source/config', { - 'is-installed-globally': true, - 'pkg-dir': async () => { - return pathPkgDir; - }, - os: { - homedir: homedirStub - } - })()); +import esmock from 'esmock'; +import {readPackage} from '../source/util.js'; + +const testedModulePath = '../source/config.js'; + +const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); + +const getConfigWhenGlobalBinaryIsUsed = async pathPackageDirectory => { + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': true, }); - return Promise.all(promises); + return getConfig(pathPackageDirectory); }; -const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { - const homedirs = [path.resolve(fixtureBasePath, 'homedir1'), - path.resolve(fixtureBasePath, 'homedir2')]; - - const promises = []; - homedirs.forEach(homedir => { - promises.push(proxyquire('../source/config', { - 'is-installed-globally': false, - 'pkg-dir': async () => { - return pathPkgDir; - }, - os: { - homedir: () => { - return homedir; - } - } - })()); +const getConfigWhenLocalBinaryIsUsed = async pathPackageDirectory => { + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, }); - return Promise.all(promises); + return getConfig(pathPackageDirectory); }; -test('returns config from home directory when global binary is used and `.np-config-json` exists in home directory', async t => { - const homedirStub = sinon.stub(); - homedirStub.returns(path.resolve(fixtureBasePath, 'homedir1')); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); - configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.json'})); +const useGlobalBinary = test.macro(async (t, packageDirectory, source) => { + const config = await getConfigWhenGlobalBinaryIsUsed(getFixture(packageDirectory)); + t.deepEqual(config, {source}); }); -test('returns config from home directory when global binary is used and `.np-config.js` exists in home directory', async t => { - const homedirStub = sinon.stub(); - homedirStub.returns(path.resolve(fixtureBasePath, 'homedir2')); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); - configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.js'})); +const useLocalBinary = test.macro(async (t, packageDirectory, source) => { + const config = await getConfigWhenLocalBinaryIsUsed(getFixture(packageDirectory)); + t.deepEqual(config, {source}); }); -test('returns config from package directory when local binary is used and `package.json` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'pkg-dir')); - configs.forEach(config => t.deepEqual(config, {source: 'package.json'})); +test( + 'returns config from package directory when global binary is used and `package.json` exists in package directory', + useGlobalBinary, + 'pkg-dir', + 'package.json', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.json` exists in package directory', + useGlobalBinary, + 'local1', + 'packagedir/.np-config.json', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.js` as CJS exists in package directory', + useGlobalBinary, + 'local2', + 'packagedir/.np-config.js', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.cjs` exists in package directory', + useGlobalBinary, + 'local3', + 'packagedir/.np-config.cjs', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.js` as ESM exists in package directory', + useGlobalBinary, + 'local4', + 'packagedir/.np-config.js', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.mjs` exists in package directory', + useGlobalBinary, + 'local5', + 'packagedir/.np-config.mjs', +); + +test('global binary merges global and project config with project taking precedence', async t => { + const fixtureDirectory = getFixture('pkg-dir'); + + // Create a temporary home directory with global config + const temporaryHome = getFixture('homedir1'); + + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': true, + 'node:os': {homedir: () => temporaryHome}, + }); + + const config = await getConfig(fixtureDirectory); + + // Should have project config + t.is(config.source, 'package.json'); }); -test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local1')); - configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.json'})); +test( + 'returns config from package directory when local binary is used and `package.json` exists in package directory', + useLocalBinary, + 'pkg-dir', + 'package.json', +); + +test( + 'returns config from package directory when local binary is used and `.np-config.json` exists in package directory', + useLocalBinary, + 'local1', + 'packagedir/.np-config.json', +); + +test( + 'returns config from package directory when local binary is used and `.np-config.js` as CJS exists in package directory', + useLocalBinary, + 'local2', + 'packagedir/.np-config.js', +); + +test( + 'returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', + useLocalBinary, + 'local3', + 'packagedir/.np-config.cjs', +); + +test( + 'returns config from package directory when local binary is used and `.np-config.js` as ESM exists in package directory', + useLocalBinary, + 'local4', + 'packagedir/.np-config.js', +); + +test( + 'returns config from package directory when local binary is used and `.np-config.mjs` exists in package directory', + useLocalBinary, + 'local5', + 'packagedir/.np-config.mjs', +); + +test('`contents` option in config allows reading package from subdirectory', async t => { + const fixtureDirectory = getFixture('contents-option'); + + // Load config from fixture directory (simulates loading from process.cwd()) + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, + }); + + const config = await getConfig(fixtureDirectory); + + // Config should have contents option + t.is(config.contents, 'dist'); + + // Using contents from config should read package from subdirectory + const contentsPath = path.join(fixtureDirectory, config.contents); + const {package_, rootDirectory} = await readPackage(contentsPath); + + t.is(package_.name, 'from-dist'); + t.is(rootDirectory, contentsPath); }); -test('returns config from package directory when local binary is used and `.np-config.js` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local2')); - configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.js'})); +test('config values override defaults', async t => { + const fixtureDirectory = getFixture('flag-precedence'); + + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, + }); + + const config = await getConfig(fixtureDirectory); + + // Config should override default values + t.is(config.tests, false); + t.is(config.cleanup, false); + t.is(config.publish, false); }); diff --git a/test/fixtures/config/contents-option/.np-config.json b/test/fixtures/config/contents-option/.np-config.json new file mode 100644 index 00000000..9e8e7bcd --- /dev/null +++ b/test/fixtures/config/contents-option/.np-config.json @@ -0,0 +1,3 @@ +{ + "contents": "dist" +} diff --git a/test/fixtures/config/contents-option/dist/package.json b/test/fixtures/config/contents-option/dist/package.json new file mode 100644 index 00000000..a689d59d --- /dev/null +++ b/test/fixtures/config/contents-option/dist/package.json @@ -0,0 +1,4 @@ +{ + "name": "from-dist", + "version": "1.0.0" +} diff --git a/test/fixtures/config/flag-precedence/.np-config.json b/test/fixtures/config/flag-precedence/.np-config.json new file mode 100644 index 00000000..87eeb50e --- /dev/null +++ b/test/fixtures/config/flag-precedence/.np-config.json @@ -0,0 +1,5 @@ +{ + "tests": false, + "cleanup": false, + "publish": false +} diff --git a/test/fixtures/config/flag-precedence/package.json b/test/fixtures/config/flag-precedence/package.json new file mode 100644 index 00000000..f379bf99 --- /dev/null +++ b/test/fixtures/config/flag-precedence/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-flag-precedence", + "version": "1.0.0" +} diff --git a/test/fixtures/config/homedir3/.np-config.cjs b/test/fixtures/config/homedir3/.np-config.cjs new file mode 100644 index 00000000..7b96f65b --- /dev/null +++ b/test/fixtures/config/homedir3/.np-config.cjs @@ -0,0 +1,3 @@ +module.exports = { + source: 'homedir/.np-config.cjs' +}; diff --git a/test/fixtures/config/homedir4/.np-config.js b/test/fixtures/config/homedir4/.np-config.js new file mode 100644 index 00000000..a91f20d0 --- /dev/null +++ b/test/fixtures/config/homedir4/.np-config.js @@ -0,0 +1,3 @@ +export default { + source: 'homedir/.np-config.js' +}; diff --git a/test/fixtures/config/homedir5/.np-config.mjs b/test/fixtures/config/homedir5/.np-config.mjs new file mode 100644 index 00000000..7565b8fb --- /dev/null +++ b/test/fixtures/config/homedir5/.np-config.mjs @@ -0,0 +1,3 @@ +export default { + source: 'homedir/.np-config.mjs' +}; diff --git a/test/fixtures/config/local3/.np-config.cjs b/test/fixtures/config/local3/.np-config.cjs new file mode 100644 index 00000000..fbf635db --- /dev/null +++ b/test/fixtures/config/local3/.np-config.cjs @@ -0,0 +1,3 @@ +module.exports = { + source: 'packagedir/.np-config.cjs' +}; diff --git a/test/fixtures/config/local4/.np-config.js b/test/fixtures/config/local4/.np-config.js new file mode 100644 index 00000000..41bc0e49 --- /dev/null +++ b/test/fixtures/config/local4/.np-config.js @@ -0,0 +1,3 @@ +export default { + source: 'packagedir/.np-config.js' +}; diff --git a/test/fixtures/config/local4/package.json b/test/fixtures/config/local4/package.json new file mode 100644 index 00000000..6509d65a --- /dev/null +++ b/test/fixtures/config/local4/package.json @@ -0,0 +1,4 @@ +{ + "name": "use-type-module-for-config-fixtures", + "type": "module" +} diff --git a/test/fixtures/config/local5/.np-config.mjs b/test/fixtures/config/local5/.np-config.mjs new file mode 100644 index 00000000..90b0f8f5 --- /dev/null +++ b/test/fixtures/config/local5/.np-config.mjs @@ -0,0 +1,3 @@ +export default { + source: 'packagedir/.np-config.mjs' +}; diff --git a/test/fixtures/config/package.json b/test/fixtures/config/package.json new file mode 100644 index 00000000..7ad6eeb0 --- /dev/null +++ b/test/fixtures/config/package.json @@ -0,0 +1,3 @@ +{ + "name": "override-type-module-for-config-fixtures" +} diff --git a/test/fixtures/files/dot-github/.github/pull_request_template.md b/test/fixtures/files/dot-github/.github/pull_request_template.md new file mode 100644 index 00000000..5c30d3f2 --- /dev/null +++ b/test/fixtures/files/dot-github/.github/pull_request_template.md @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/files/dot-github/index.js b/test/fixtures/files/dot-github/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/dot-github/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/dot-github/package.json b/test/fixtures/files/dot-github/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/dot-github/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/failing-prepack-script/index.js b/test/fixtures/files/failing-prepack-script/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/failing-prepack-script/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/failing-prepack-script/package.json b/test/fixtures/files/failing-prepack-script/package.json new file mode 100644 index 00000000..7e1ab4f5 --- /dev/null +++ b/test/fixtures/files/failing-prepack-script/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"], + "scripts": { + "prepack": "exit 1" + } +} diff --git a/test/fixtures/files/files-and-npmignore/package.json b/test/fixtures/files/files-and-npmignore/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/files-and-npmignore/readme.md b/test/fixtures/files/files-and-npmignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/files-and-npmignore/source/.npmignore b/test/fixtures/files/files-and-npmignore/source/.npmignore new file mode 100644 index 00000000..b0b0549d --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/.npmignore @@ -0,0 +1 @@ +index.test-d.ts diff --git a/test/fixtures/files/files-and-npmignore/source/bar.js b/test/fixtures/files/files-and-npmignore/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/files-and-npmignore/source/foo.js b/test/fixtures/files/files-and-npmignore/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/files-and-npmignore/source/index.d.ts b/test/fixtures/files/files-and-npmignore/source/index.d.ts new file mode 100644 index 00000000..ce96e9a6 --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/index.d.ts @@ -0,0 +1,2 @@ +export function foo(): string; +export function bar(): string; 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 new file mode 100644 index 00000000..cd87de16 --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts @@ -0,0 +1,5 @@ +import {expectType} from 'tsd'; +import {foo, bar} from './index.js'; + +expectType(foo()); +expectType(bar()); diff --git a/test/fixtures/files/files-slash/index.js b/test/fixtures/files/files-slash/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/files-slash/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/files-slash/package.json b/test/fixtures/files/files-slash/package.json new file mode 100644 index 00000000..850e9c9f --- /dev/null +++ b/test/fixtures/files/files-slash/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["/index.js"] +} diff --git a/test/fixtures/files/gitignore/.gitignore b/test/fixtures/files/gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/gitignore/dist/index.js b/test/fixtures/files/gitignore/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/gitignore/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/gitignore/index.d.ts b/test/fixtures/files/gitignore/index.d.ts new file mode 100644 index 00000000..07134df8 --- /dev/null +++ b/test/fixtures/files/gitignore/index.d.ts @@ -0,0 +1 @@ +export default function foo(): string; diff --git a/test/fixtures/files/gitignore/index.js b/test/fixtures/files/gitignore/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/gitignore/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/gitignore/index.test-d.ts b/test/fixtures/files/gitignore/index.test-d.ts new file mode 100644 index 00000000..85f60f8e --- /dev/null +++ b/test/fixtures/files/gitignore/index.test-d.ts @@ -0,0 +1,4 @@ +import {expectType} from 'tsd'; +import foo from './index.js'; + +expectType(foo()); diff --git a/test/fixtures/files/gitignore/package.json b/test/fixtures/files/gitignore/package.json new file mode 100644 index 00000000..745fec5b --- /dev/null +++ b/test/fixtures/files/gitignore/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["dist"] +} diff --git a/test/fixtures/files/gitignore/readme.md b/test/fixtures/files/gitignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/gitignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/has-readme-and-license/index.js b/test/fixtures/files/has-readme-and-license/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/has-readme-and-license/license.md b/test/fixtures/files/has-readme-and-license/license.md new file mode 100644 index 00000000..a22a2da2 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/license.md @@ -0,0 +1 @@ +MIT diff --git a/test/fixtures/files/has-readme-and-license/package.json b/test/fixtures/files/has-readme-and-license/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/has-readme-and-license/readme.md b/test/fixtures/files/has-readme-and-license/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/main/bar.js b/test/fixtures/files/main/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/main/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/main/foo.js b/test/fixtures/files/main/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/main/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/main/package.json b/test/fixtures/files/main/package.json new file mode 100644 index 00000000..10a4cbd9 --- /dev/null +++ b/test/fixtures/files/main/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "version": "0.0.0", + "main": "foo.js", + "files": ["bar.js"] +} diff --git a/test/fixtures/files/missing-bin/index.js b/test/fixtures/files/missing-bin/index.js new file mode 100644 index 00000000..d02ba545 --- /dev/null +++ b/test/fixtures/files/missing-bin/index.js @@ -0,0 +1 @@ +export default 'foo'; diff --git a/test/fixtures/files/missing-bin/package.json b/test/fixtures/files/missing-bin/package.json new file mode 100644 index 00000000..0a34283f --- /dev/null +++ b/test/fixtures/files/missing-bin/package.json @@ -0,0 +1,6 @@ +{ + "name": "missing-bin", + "version": "0.0.0", + "bin": "./cli.js", + "files": ["index.js"] +} diff --git a/test/fixtures/files/missing-main/package.json b/test/fixtures/files/missing-main/package.json new file mode 100644 index 00000000..8429a91c --- /dev/null +++ b/test/fixtures/files/missing-main/package.json @@ -0,0 +1,6 @@ +{ + "name": "missing-main", + "version": "0.0.0", + "main": "dist/index.js", + "files": ["source"] +} diff --git a/test/fixtures/files/missing-main/source/index.js b/test/fixtures/files/missing-main/source/index.js new file mode 100644 index 00000000..d02ba545 --- /dev/null +++ b/test/fixtures/files/missing-main/source/index.js @@ -0,0 +1 @@ +export default 'foo'; diff --git a/test/fixtures/files/npmignore-and-gitignore/.gitignore b/test/fixtures/files/npmignore-and-gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/npmignore-and-gitignore/.npmignore b/test/fixtures/files/npmignore-and-gitignore/.npmignore new file mode 100644 index 00000000..7dbdd130 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/.npmignore @@ -0,0 +1,2 @@ +script/ +source/ diff --git a/test/fixtures/files/npmignore-and-gitignore/dist/index.js b/test/fixtures/files/npmignore-and-gitignore/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/npmignore-and-gitignore/package.json b/test/fixtures/files/npmignore-and-gitignore/package.json new file mode 100644 index 00000000..3538ae77 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "0.0.0" +} diff --git a/test/fixtures/files/npmignore-and-gitignore/readme.md b/test/fixtures/files/npmignore-and-gitignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/npmignore-and-gitignore/script/build.js b/test/fixtures/files/npmignore-and-gitignore/script/build.js new file mode 100644 index 00000000..94c94ba5 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/script/build.js @@ -0,0 +1 @@ +// ... yada yada yada diff --git a/test/fixtures/files/npmignore-and-gitignore/source/index.ts b/test/fixtures/files/npmignore-and-gitignore/source/index.ts new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/source/index.ts @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/npmignore/.npmignore b/test/fixtures/files/npmignore/.npmignore new file mode 100644 index 00000000..b0b0549d --- /dev/null +++ b/test/fixtures/files/npmignore/.npmignore @@ -0,0 +1 @@ +index.test-d.ts diff --git a/test/fixtures/files/npmignore/index.d.ts b/test/fixtures/files/npmignore/index.d.ts new file mode 100644 index 00000000..07134df8 --- /dev/null +++ b/test/fixtures/files/npmignore/index.d.ts @@ -0,0 +1 @@ +export default function foo(): string; diff --git a/test/fixtures/files/npmignore/index.js b/test/fixtures/files/npmignore/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/npmignore/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/npmignore/index.test-d.ts b/test/fixtures/files/npmignore/index.test-d.ts new file mode 100644 index 00000000..85f60f8e --- /dev/null +++ b/test/fixtures/files/npmignore/index.test-d.ts @@ -0,0 +1,4 @@ +import {expectType} from 'tsd'; +import foo from './index.js'; + +expectType(foo()); diff --git a/test/fixtures/files/npmignore/package.json b/test/fixtures/files/npmignore/package.json new file mode 100644 index 00000000..3538ae77 --- /dev/null +++ b/test/fixtures/files/npmignore/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "0.0.0" +} diff --git a/test/fixtures/files/npmignore/readme.md b/test/fixtures/files/npmignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/npmignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/one-file/index.js b/test/fixtures/files/one-file/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/one-file/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/one-file/package.json b/test/fixtures/files/one-file/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/one-file/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/prepack-generated-entry-point/package.json b/test/fixtures/files/prepack-generated-entry-point/package.json new file mode 100644 index 00000000..56ea4562 --- /dev/null +++ b/test/fixtures/files/prepack-generated-entry-point/package.json @@ -0,0 +1,9 @@ +{ + "name": "prepack-generated-entry-point", + "version": "0.0.0", + "main": "dist/index.js", + "files": ["dist"], + "scripts": { + "prepack": "node --input-type=module -e \"import fs from 'node:fs'; fs.mkdirSync('dist', {recursive: true}); fs.writeFileSync('dist/index.js', 'export default 1;\\n');\"" + } +} diff --git a/test/fixtures/files/prepare-script/index.js b/test/fixtures/files/prepare-script/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/prepare-script/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/prepare-script/package.json b/test/fixtures/files/prepare-script/package.json new file mode 100644 index 00000000..d4140564 --- /dev/null +++ b/test/fixtures/files/prepare-script/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"], + "scripts": { + "prepare": "echo '[build] compiling'" + } +} diff --git a/test/fixtures/files/source-and-dist-dir/dist/index.js b/test/fixtures/files/source-and-dist-dir/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/source-and-dist-dir/package.json b/test/fixtures/files/source-and-dist-dir/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/source-and-dist-dir/source/bar.js b/test/fixtures/files/source-and-dist-dir/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/source-and-dist-dir/source/foo.js b/test/fixtures/files/source-and-dist-dir/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/source-dir/package.json b/test/fixtures/files/source-dir/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/source-dir/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/source-dir/source/bar.js b/test/fixtures/files/source-dir/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/source-dir/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/source-dir/source/foo.js b/test/fixtures/files/source-dir/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/source-dir/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/readme.md b/test/fixtures/readme.md new file mode 100644 index 00000000..c0816aa2 --- /dev/null +++ b/test/fixtures/readme.md @@ -0,0 +1,2 @@ +The directory is for the resources +in the script npmignore.js diff --git a/test/git-tasks.js b/test/git-tasks.js deleted file mode 100644 index 3fd73534..00000000 --- a/test/git-tasks.js +++ /dev/null @@ -1,133 +0,0 @@ -import test from 'ava'; -import execaStub from 'execa_test_double'; -import mockery from 'mockery'; -import {SilentRenderer} from './fixtures/listr-renderer'; - -let testedModule; - -const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -test.before(() => { - mockery.registerMock('execa', execaStub.execa); - mockery.enable({ - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - }); - testedModule = require('../source/git-tasks'); -}); - -test.beforeEach(() => { - execaStub.resetStub(); -}); - -test.serial('should fail when release branch is not specified, current branch is not main/master and publishing from any branch not permitted', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'feature' - } - ]); - await t.throwsAsync(run(testedModule({})), - {message: 'Not on `main`/`master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check current branch' && task.hasFailed())); -}); - -test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'feature' - } - ]); - await t.throwsAsync(run(testedModule({branch: 'release'})), - {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check current branch' && task.hasFailed())); -}); - -test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'feature' - }, - { - command: 'git status --porcelain', - exitCode: 0, - stdout: '' - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - exitCode: 0, - stdout: '' - } - ]); - await run(testedModule({anyBranch: true})); - t.false(SilentRenderer.tasks.some(task => task.title === 'Check current branch')); -}); - -test.serial('should fail when local working tree modified', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'master' - }, - { - command: 'git status --porcelain', - exitCode: 0, - stdout: 'M source/git-tasks.js' - } - ]); - await t.throwsAsync(run(testedModule({})), {message: 'Unclean working tree. Commit or stash changes first.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check local working tree' && task.hasFailed())); -}); - -test.serial('should fail when remote history differs', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'master' - }, - { - command: 'git status --porcelain', - exitCode: 0, - stdout: '' - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - exitCode: 0, - stdout: '1' - } - ]); - await t.throwsAsync(run(testedModule({})), {message: 'Remote history differs. Please pull changes.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check remote history' && task.hasFailed())); -}); - -test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'master' - }, - { - command: 'git status --porcelain', - exitCode: 0, - stdout: '' - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - exitCode: 0, - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule({}))); -}); diff --git a/test/git-util/commit-log-from-revision.js b/test/git-util/commit-log-from-revision.js new file mode 100644 index 00000000..bf0a780b --- /dev/null +++ b/test/git-util/commit-log-from-revision.js @@ -0,0 +1,32 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns single commit', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const {sha, commitMessage} = await t.context.commitNewFile(); + + t.is(await commitLogFromRevision('v0.0.0'), `${commitMessage} ${sha}`); +}); + +test('returns multiple commits, from newest to oldest', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const commit1 = await t.context.commitNewFile(); + const commit2 = await t.context.commitNewFile(); + const commit3 = await t.context.commitNewFile(); + + const commitLog = stripIndent` + ${commit3.commitMessage} ${commit3.sha} + ${commit2.commitMessage} ${commit2.sha} + ${commit1.commitMessage} ${commit1.sha} + `; + + t.is(await commitLogFromRevision('v0.0.0'), commitLog); +}); diff --git a/test/git-util/default-branch.js b/test/git-util/default-branch.js new file mode 100644 index 00000000..07fc1c6a --- /dev/null +++ b/test/git-util/default-branch.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('main', createFixture, async ({$$}) => { + await $$`git checkout -B main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'main'); +}); + +test('master', createFixture, async ({$$}) => { + await $$`git checkout -B master`; + await $$`git update-ref -d refs/heads/main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'master'); +}); + +test('gh-pages', createFixture, async ({$$}) => { + await $$`git checkout -B gh-pages`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'gh-pages'); +}); + +test('fails', createFixture, async ({$$}) => { + await $$`git checkout -B unicorn`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + await t.throwsAsync( + defaultBranch(), + {message: 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'}, + ); +}); diff --git a/test/git-util/delete-tag.js b/test/git-util/delete-tag.js new file mode 100644 index 00000000..15c54016 --- /dev/null +++ b/test/git-util/delete-tag.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('deletes given tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v1.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.is(tags, 'v0.0.0'); +}); + +test('deletes given tag from a large list', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; + await $$`git tag v2.0.0`; + await $$`git tag v3.0.0`; + await $$`git tag v4.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v2.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.deepEqual( + tags.split('\n'), + ['v0.0.0', 'v1.0.0', 'v3.0.0', 'v4.0.0'], + ); +}); + +test('throws if tag not found', createFixture, async () => { + // +}, async ({t, testedModule: {deleteTag}}) => { + await t.throwsAsync( + deleteTag('v1.0.0'), + {message: /error: tag 'v1\.0\.0' not found\./}, + ); +}); diff --git a/test/git-util/get-current-branch.js b/test/git-util/get-current-branch.js new file mode 100644 index 00000000..14623264 --- /dev/null +++ b/test/git-util/get-current-branch.js @@ -0,0 +1,12 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns current branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {getCurrentBranch}}) => { + const currentBranch = await getCurrentBranch(); + t.is(currentBranch, 'unicorn'); +}); diff --git a/test/git-util/has-upstream.js b/test/git-util/has-upstream.js new file mode 100644 index 00000000..43e67aec --- /dev/null +++ b/test/git-util/has-upstream.js @@ -0,0 +1,11 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no upstream', createFixture, async () => { + // +}, async ({t, testedModule: {hasUpstream}}) => { + t.false(await hasUpstream()); +}); diff --git a/test/git-util/is-head-detached.js b/test/git-util/is-head-detached.js new file mode 100644 index 00000000..85de14a2 --- /dev/null +++ b/test/git-util/is-head-detached.js @@ -0,0 +1,18 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('not detached', createFixture, async () => { + // +}, async ({t, testedModule: {isHeadDetached}}) => { + t.false(await isHeadDetached()); +}); + +test('detached', createFixture, async ({$$}) => { + const {stdout: firstCommitSha} = await $$`git rev-list --max-parents=0 HEAD`; + await $$`git checkout ${firstCommitSha}`; +}, async ({t, testedModule: {isHeadDetached}}) => { + t.true(await isHeadDetached()); +}); diff --git a/test/git-util/latest-tag-or-first-commit.js b/test/git-util/latest-tag-or-first-commit.js new file mode 100644 index 00000000..526938c4 --- /dev/null +++ b/test/git-util/latest-tag-or-first-commit.js @@ -0,0 +1,33 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +// From https://stackoverflow.com/a/3357357/10292952 +const getCommitMessage = async ($$, sha) => $$`git log --format=%B -n 1 ${sha}`; + +test('one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v1.0.0'); +}); + +test('no tags (fallback)', createFixture, async () => { + // +}, async ({t, testedModule: {latestTagOrFirstCommit}, $$}) => { + const result = await latestTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + + t.is(firstCommitMessage.trim(), '"init1"'); +}); diff --git a/test/git-util/latest-tag.js b/test/git-util/latest-tag.js new file mode 100644 index 00000000..9c876f85 --- /dev/null +++ b/test/git-util/latest-tag.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns latest tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v0.0.0'); +}); + +test('returns latest tag - multiple set', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v4.0.0'); +}); diff --git a/test/git-util/multiple-initial-commits.js b/test/git-util/multiple-initial-commits.js new file mode 100644 index 00000000..2f09b463 --- /dev/null +++ b/test/git-util/multiple-initial-commits.js @@ -0,0 +1,32 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('repository with multiple initial commits', createFixture, async ({t, $$}) => { + // Get the current branch name before creating orphan branch + const {stdout: initialBranch} = await $$`git branch --show-current`; + + // Create a second orphan branch to simulate multiple initial commits + await $$`git checkout --orphan other-branch`; + await t.context.createFile('other-file'); + await $$`git add other-file`; + await $$`git commit -m orphan`; + + // Merge the orphan branch into the initial branch, creating multiple root commits + await $$`git checkout ${initialBranch}`; + await $$`git merge --allow-unrelated-histories other-branch -m merge`; +}, async ({t, testedModule: {latestTagOrFirstCommit, commitLogFromRevision}}) => { + // This should not throw an error even with multiple initial commits + const result = await latestTagOrFirstCommit(); + + // Verify result is a valid commit hash (single line) + t.false(result.includes('\n'), 'Result should be a single commit hash'); + t.is(result.length, 40, 'Result should be a 40-character SHA-1 hash'); + + // This was the operation that failed in the original issue + await t.notThrowsAsync(async () => { + await commitLogFromRevision(result); + }, 'commitLogFromRevision should work with the returned first commit'); +}); diff --git a/test/git-util/new-files-since-last-release.js b/test/git-util/new-files-since-last-release.js new file mode 100644 index 00000000..7cf68d65 --- /dev/null +++ b/test/git-util/new-files-since-last-release.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns files added since latest tag', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); + t.deepEqual( + newFiles.sort(), + ['new', 'index.js'].sort(), + ); +}); + +test('no files', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); + 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}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); + t.deepEqual( + newFiles.sort(), + ['index.js', 'package.json', '.gitignore'].sort(), + ); +}); diff --git a/test/git-util/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js new file mode 100644 index 00000000..7347a50f --- /dev/null +++ b/test/git-util/previous-tag-or-first-commit.js @@ -0,0 +1,63 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no tags', createFixture, () => { + // +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, undefined); +}); + +test('one tag - fallback to first commit', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + const commitMessage = await t.context.getCommitMessage(result); + + t.is(commitMessage, t.context.firstCommitMessage); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('multiple tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v3.0.0'); +}); + +test('tags created out of order - should sort by semver not creation date', createFixture, async ({t, $$}) => { + // Create tags out of semver order (simulating a hotfix scenario) + await $$`git tag v1.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.2.0`; + await t.context.commitNewFile(); + await $$`git tag v1.2.1`; + await t.context.commitNewFile(); + // Create a hotfix tag for an older version (created after v1.2.1 but semver is lower) + await $$`git tag v1.0.1`; + await t.context.commitNewFile(); + await $$`git tag v1.2.2`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + // Should return v1.2.1 (semver previous), not v1.0.1 (creation date previous) + const result = await previousTagOrFirstCommit(); + t.is(result, 'v1.2.1'); +}); + +test.todo('test fallback case'); diff --git a/test/git-util/push-graceful.js b/test/git-util/push-graceful.js new file mode 100644 index 00000000..8999fb7e --- /dev/null +++ b/test/git-util/push-graceful.js @@ -0,0 +1,66 @@ +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)); +}); + +test('pushes to custom remote', createFixture, [{ + command: 'git push upstream --follow-tags', + exitCode: 0, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.notThrowsAsync(pushGraceful(false, 'upstream')); +}); + +test('throws with custom remote', createFixture, [{ + command: 'git push upstream --follow-tags', + exitCode: 1, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.throwsAsync(pushGraceful(false, 'upstream')); +}); + +test('pushes tags only to custom remote on branch protection error', createFixture, [ + { + command: 'git push upstream --follow-tags', + stderr: 'GH006', + }, + { + command: 'git push upstream --tags', + exitCode: 0, + }, +], async ({t, testedModule: {pushGraceful}}) => { + const {pushed, reason} = await pushGraceful(true, 'upstream'); + + t.is(pushed, 'tags'); + t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); +}); + diff --git a/test/git-util/read-file-from-last-release.js b/test/git-util/read-file-from-last-release.js new file mode 100644 index 00000000..7d945f63 --- /dev/null +++ b/test/git-util/read-file-from-last-release.js @@ -0,0 +1,43 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns content of a given file', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn-1'); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn-2'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + const file = await readFileFromLastRelease('unicorn.txt'); + t.is(file, 'unicorn-1'); +}); + +test('fails if file not in previous release', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: path '[^']*' exists on disk, but not in 'v0\.0\.0'/}, + ); +}); + +test('no previous release', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: No names found, cannot describe anything./}, + ); +}); + +// These errors could probably be handled in 'readFileFromLastRelease' diff --git a/test/git-util/remove-last-commit.js b/test/git-util/remove-last-commit.js new file mode 100644 index 00000000..de7d794f --- /dev/null +++ b/test/git-util/remove-last-commit.js @@ -0,0 +1,19 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('removes latest commit', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {removeLastCommit}, $$}) => { + const {stdout: commitsBefore} = await $$`git log --pretty="%s"`; + t.true(commitsBefore.includes('"added"')); + + await removeLastCommit(); + + const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; + t.false(commitsAfter.includes('"added"')); +}); diff --git a/test/git-util/root.js b/test/git-util/root.js new file mode 100644 index 00000000..b5bd3497 --- /dev/null +++ b/test/git-util/root.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; +import {npRootDirectory} 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(), npRootDirectory); +}); + +test('returns root dir of temp dir', createFixture, () => { + // +}, async ({t, testedModule: git, temporaryDirectory}) => { + t.is(await git.root(), temporaryDirectory); +}); 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..df2204cb --- /dev/null +++ b/test/git-util/verify-current-branch-is-release-branch.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.notThrowsAsync(verifyCurrentBranchIsReleaseBranch('unicorn')); +}); + +test('not on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.throwsAsync( + verifyCurrentBranchIsReleaseBranch('main'), + {message: 'Not on `main` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); +}); diff --git a/test/git-util/verify-recent-git-version.js b/test/git-util/verify-recent-git-version.js new file mode 100644 index 00000000..55e54182 --- /dev/null +++ b/test/git-util/verify-recent-git-version.js @@ -0,0 +1,22 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.12.0', // One higher than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.notThrowsAsync(verifyRecentGitVersion()); +}); + +test('not satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.10.0', // One lower than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.throwsAsync( + verifyRecentGitVersion(), + {message: '`np` requires git >=2.11.0'}, + ); +}); diff --git a/test/git-util/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js new file mode 100644 index 00000000..8071cebd --- /dev/null +++ b/test/git-util/verify-remote-history-is-clean.js @@ -0,0 +1,68 @@ +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}}) => { + await t.notThrowsAsync(verifyRemoteHistoryIsClean()); +}); diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js new file mode 100644 index 00000000..fe00e6cf --- /dev/null +++ b/test/git-util/verify-remote-is-valid.js @@ -0,0 +1,32 @@ +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}, + ); +}); + +test('has custom remote', createStubFixture, [{ + command: 'git ls-remote upstream HEAD', + exitCode: 0, +}], async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.notThrowsAsync(verifyRemoteIsValid('upstream')); +}); diff --git a/test/git-util/verify-tag-does-not-exist-on-remote.js b/test/git-util/verify-tag-does-not-exist-on-remote.js new file mode 100644 index 00000000..2827d4b2 --- /dev/null +++ b/test/git-util/verify-tag-does-not-exist-on-remote.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('exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + stdout: '123456789', // Some hash +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.throwsAsync( + verifyTagDoesNotExistOnRemote('v0.0.0'), + {message: 'Git tag `v0.0.0` already exists.'}, + ); +}); + +test('does not exist', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + exitCode: 1, + stderr: '', + stdout: '', +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.notThrowsAsync(verifyTagDoesNotExistOnRemote('v0.0.0')); +}); diff --git a/test/git-util/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js new file mode 100644 index 00000000..90b1e8b2 --- /dev/null +++ b/test/git-util/verify-working-tree-is-clean.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('clean', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.notThrowsAsync(verifyWorkingTreeIsClean()); +}); + +test('not clean', createFixture, async ({t}) => { + await t.context.createFile('index.js'); +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.throwsAsync( + verifyWorkingTreeIsClean(), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); +}); diff --git a/test/hyperlinks.js b/test/hyperlinks.js deleted file mode 100644 index 98b0632d..00000000 --- a/test/hyperlinks.js +++ /dev/null @@ -1,56 +0,0 @@ -import test from 'ava'; -import sinon from 'sinon'; -import terminalLink from 'terminal-link'; -import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util'; - -const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; -const MOCK_COMMIT_HASH = '5063f8a'; -const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; - -const sandbox = sinon.createSandbox(); - -test.afterEach(() => { - sandbox.restore(); -}); - -const mockTerminalLinkUnsupported = () => - sandbox.stub(terminalLink, 'isSupported').value(false); - -test('linkifyIssues correctly links issues', t => { - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); -}); - -test('linkifyIssues returns raw message if url is not provided', t => { - const msg = 'Commit message - fixes #5'; - t.is(linkifyIssues(undefined, msg), msg); -}); - -test.serial('linkifyIssues returns raw message if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - const msg = 'Commit message - fixes #6'; - t.is(linkifyIssues(MOCK_REPO_URL, msg), msg); -}); - -test('linkifyCommit correctly links commits', t => { - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); -}); - -test('linkifyCommit returns raw commit hash if url is not provided', t => { - t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test.serial('linkifyCommit returns raw commit hash if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test('linkifyCommitRange returns raw commitRange if url is not provided', t => { - t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); - -test.serial('linkifyCommitRange returns raw commitRange if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); diff --git a/test/index.js b/test/index.js index d011b7be..91729173 100644 --- a/test/index.js +++ b/test/index.js @@ -1,63 +1,97 @@ +import path from 'node:path'; +import process from 'node:process'; import test from 'ava'; import sinon from 'sinon'; -import proxyquire from 'proxyquire'; -import np from '../source'; +import esmock from 'esmock'; +import {of, throwError} from 'rxjs'; +import {npmConfig as packageManager} from '../source/package-manager/configs.js'; +import * as util from '../source/util.js'; const defaultOptions = { cleanup: true, tests: true, publish: true, + packageManager, runPublish: true, availability: { isAvailable: false, - isUnknown: false - } + isUnknown: false, + }, + renderer: 'silent', }; -test('version is invalid', async t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; - await t.throwsAsync(np('foo', defaultOptions), message); - await t.throwsAsync(np('4.x.3', defaultOptions), message); -}); +const npPackageResult = await util.readPackage(); -test('version is pre-release', async t => { - const message = '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'; - await t.throwsAsync(np('premajor', defaultOptions), message); - await t.throwsAsync(np('preminor', defaultOptions), message); - await t.throwsAsync(np('prepatch', defaultOptions), message); - await t.throwsAsync(np('prerelease', defaultOptions), message); - await t.throwsAsync(np('10.0.0-0', defaultOptions), message); - await t.throwsAsync(np('10.0.0-beta', defaultOptions), message); +const getNpMock = async () => esmock('../source/index.js', {}, { + execa: {execa: sinon.stub().resolves({stdout: '10.0.0', stderr: ''})}, + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + verifyCurrentBranchIsReleaseBranch: sinon.stub(), + verifyRemoteHistoryIsClean: sinon.stub(), + verifyRemoteIsValid: sinon.stub(), + verifyRecentGitVersion: sinon.stub(), + fetch: sinon.stub(), + verifyTagDoesNotExistOnRemote: sinon.stub(), + }, + '../source/npm/util.js': { + ...await import('../source/npm/util.js'), + checkConnection: sinon.stub().resolves(), + }, }); -test('errors on too low version', async t => { - await t.throwsAsync(np('1.0.0', defaultOptions), /New version `1\.0\.0` should be higher than current version `\d+\.\d+\.\d+`/); - await t.throwsAsync(np('1.0.0-beta', defaultOptions), /New version `1\.0\.0-beta` should be higher than current version `\d+\.\d+\.\d+`/); +const npFails = test.macro(async (t, inputs, message) => { + const npMock = await getNpMock(); + await t.throwsAsync( + Promise.all(inputs.map(input => npMock(input, defaultOptions, npPackageResult))), + {message}, + ); }); +test('version is invalid', npFails, ['foo', '4.x.3'], /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, ['premajor', 'preminor', 'prepatch', 'prerelease', '100.0.0-0', '100.0.0-beta'], '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'); + +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+/); + +const fakeExecaReturn = () => Object.assign( + Promise.resolve({pipe: sinon.stub()}), + {stdout: '', stderr: ''}, +); + +const fakeObservableReturn = () => of(''); + +const fakeObservableReject = error => throwError(() => Object.assign(new Error(error), {stdout: '', stderr: error})); + test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); - const np = proxyquire('../source', { - del: sinon.stub(), - execa: sinon.stub().returns({pipe: sinon.stub()}), - './prerequisite-tasks': sinon.stub(), - './git-tasks': sinon.stub(), - './git-util': { + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), - pushGraceful: sinon.stub() + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeObservableReturn()), }, - './npm/enable-2fa': enable2faStub, - './npm/publish': sinon.stub().returns({pipe: sinon.stub()}) }); - await t.notThrowsAsync(np('1.0.0', { + await t.notThrowsAsync(npMock('1.0.0', { ...defaultOptions, availability: { isAvailable: false, - isUnknown: false - } - })); + isUnknown: false, + }, + }, npPackageResult)); t.true(enable2faStub.notCalled); }); @@ -65,27 +99,475 @@ 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(); - const np = proxyquire('../source', { - del: sinon.stub(), - execa: sinon.stub().returns({pipe: sinon.stub()}), - './prerequisite-tasks': sinon.stub(), - './git-tasks': sinon.stub(), - './git-util': { + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeObservableReturn()), + }, + }); + + await t.notThrowsAsync(npMock('1.0.0', { + ...defaultOptions, + availability: { + isAvailable: true, + isUnknown: false, + }, + '2fa': false, + }, npPackageResult)); + + t.true(enable2faStub.notCalled); +}); + +test('skip enabling 2FA in trusted publishing (OIDC) contexts', 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(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), - pushGraceful: sinon.stub() + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeObservableReturn()), + }, + '../source/npm/oidc.js': { + getOidcProvider: () => 'github', }, - './npm/enable-2fa': enable2faStub, - './npm/publish': sinon.stub().returns({pipe: sinon.stub()}) }); - await t.notThrowsAsync(np('1.0.0', { + await t.notThrowsAsync(npMock('1.0.0', { ...defaultOptions, availability: { isAvailable: true, - isUnknown: false + isUnknown: false, }, - '2fa': false - })); + '2fa': true, + }, npPackageResult)); t.true(enable2faStub.notCalled); }); + +test('rollback is called when publish fails', async t => { + const deleteTagStub = sinon.stub().resolves(); + const removeLastCommitStub = sinon.stub().resolves(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + latestTag: sinon.stub().resolves('v1.0.0'), + deleteTag: deleteTagStub, + removeLastCommit: removeLastCommitStub, + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeObservableReject('npm ERR! publish failed')), + }, + '../source/util.js': { + ...util, + readPackage: sinon.stub().resolves({version: '1.0.0'}), + getTagVersionPrefix: sinon.stub().resolves('v'), + }, + }); + + await t.throwsAsync( + npMock('1.0.0', { + ...defaultOptions, + }, {package_: {version: '0.9.0'}, rootDirectory: process.cwd()}), + {message: /Error publishing package/}, + ); + + t.true(deleteTagStub.calledOnce, 'deleteTag should be called once'); + t.true(removeLastCommitStub.calledOnce, 'removeLastCommit should be called once'); +}); + +test('publish uses rootDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.is(publishCwd, contentsDirectory, 'publish should use rootDirectory from context as cwd'); +}); + +test('rootDirectory remains the default working directory when projectDirectory is omitted', async t => { + const rootDirectory = path.resolve('dist'); + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, rootDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(rootDirectory, 'node_modules'))); + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: rootDirectory}]); + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: rootDirectory}]); + t.is(publishCwd, rootDirectory); +}); + +test('install uses projectDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + dryRun: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: projectDirectory}]); +}); + +test('cleanup uses projectDirectory from context', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const deleteAsyncStub = sinon.stub(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + dryRun: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(projectDirectory, 'node_modules'))); +}); + +test('tests use projectDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + publish: false, + runPublish: false, + dryRun: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: projectDirectory}]); +}); + +test('no-cleanup still uses lockfile-aware install command', async t => { + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns('package-lock.json'), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + tests: false, + publish: false, + runPublish: false, + dryRun: false, + }, npPackageResult); + + t.deepEqual(execaStub.firstCall.args, ['npm', ['ci', '--engine-strict'], {cwd: npPackageResult.rootDirectory}]); +}); + +test('contents mode looks up lockfile in projectDirectory and installs there', async t => { + const projectDirectory = path.resolve('.'); + const rootDirectory = path.resolve('dist'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + const findLockfileStub = sinon.stub().returns('package-lock.json'); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: findLockfileStub, + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + dryRun: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory}); + + t.true(findLockfileStub.calledOnceWithExactly(projectDirectory, packageManager)); + t.deepEqual(execaStub.firstCall.args, ['npm', ['ci', '--engine-strict'], {cwd: projectDirectory}]); +}); + +test('contents mode keeps cleanup, install, and tests in projectDirectory while publishing from rootDirectory', async t => { + const projectDirectory = path.resolve('.'); + const rootDirectory = path.resolve('dist'); + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, projectDirectory, rootDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(projectDirectory, 'node_modules'))); + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: projectDirectory}]); + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: projectDirectory}]); + t.is(publishCwd, rootDirectory); +}); + +test('dryRun with no-cleanup does not execute install command', async t => { + const execaStub = sinon.stub().returns(fakeExecaReturn()); + const verifyWorkingTreeIsCleanStub = sinon.stub(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: verifyWorkingTreeIsCleanStub, + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns('package-lock.json'), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + tests: false, + publish: false, + runPublish: false, + dryRun: true, + }, npPackageResult); + + t.true(execaStub.notCalled); + t.true(verifyWorkingTreeIsCleanStub.notCalled); +}); + +test('dryRun without lockfile does not clean up or run tests', async t => { + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + dryRun: true, + publish: false, + runPublish: false, + }, npPackageResult); + + t.true(deleteAsyncStub.notCalled); + t.true(execaStub.notCalled); +}); + +test('preview option remains a supported alias for dryRun', async t => { + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + preview: true, + publish: false, + runPublish: false, + }, npPackageResult); + + t.true(deleteAsyncStub.notCalled); + t.true(execaStub.notCalled); +}); diff --git a/test/npm/enable-2fa.js b/test/npm/enable-2fa.js new file mode 100644 index 00000000..88e69c1a --- /dev/null +++ b/test/npm/enable-2fa.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/npm/enable-2fa.js', import.meta.url); + +const npmVersionFixtures = [ + {version: '8.0.0', accessArgs: ['access', '2fa-required']}, + {version: '9.0.0', accessArgs: ['access', 'set', 'mfa=publish']}, +]; + +for (const {version, accessArgs} of npmVersionFixtures) { + const npmVersionCommand = [{ + command: 'npm --version', + stdout: version, + }]; + + test(`npm v${version} - no options`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np'), + [...accessArgs, 'np'], + ); + }); + + test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np', {confirm: true}), + [...accessArgs, 'np'], + ); + }); + + test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np', {otp: '123456'}), + [...accessArgs, 'np', '--otp', '123456'], + ); + }); +} + diff --git a/test/npm/handle-npm-error.js b/test/npm/handle-npm-error.js new file mode 100644 index 00000000..61b6959a --- /dev/null +++ b/test/npm/handle-npm-error.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import handleNpmError from '../../source/npm/handle-npm-error.js'; + +const makeError = ({code, stdout, stderr}) => ({ + code, + stdout: stdout ?? '', + stderr: stderr ?? '', +}); + +test('error code 402 - privately publish scoped package', t => { + t.throws( + () => handleNpmError(makeError({code: 402})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); + + t.throws( + () => handleNpmError(makeError({stderr: 'npm ERR! 402 Payment Required'})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); +}); diff --git a/test/npm/oidc.js b/test/npm/oidc.js new file mode 100644 index 00000000..88632efe --- /dev/null +++ b/test/npm/oidc.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import esmock from 'esmock'; + +test('detects GitHub Actions', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://example.com', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', + }, + }, + }); + + t.is(getOidcProvider(), 'github'); +}); + +test('detects GitLab CI', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: { + GITLAB_CI: 'true', + NPM_ID_TOKEN: 'token', + }, + }, + }); + + t.is(getOidcProvider(), 'gitlab'); +}); + +test('detects no OIDC', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: {}, + }, + }); + + t.is(getOidcProvider(), undefined); +}); diff --git a/test/npm/publish.js b/test/npm/publish.js new file mode 100644 index 00000000..42f6cb8e --- /dev/null +++ b/test/npm/publish.js @@ -0,0 +1,51 @@ +import test from 'ava'; +import {firstValueFrom} from 'rxjs'; +import {getPackagePublishArguments, runPublish} from '../../source/npm/publish.js'; + +test('no options set', t => { + t.deepEqual( + getPackagePublishArguments({}), + ['publish'], + ); +}); + +test('options.tag', t => { + t.deepEqual( + getPackagePublishArguments({tag: 'beta'}), + ['publish', '--tag', 'beta'], + ); +}); + +test('options.otp', t => { + t.deepEqual( + getPackagePublishArguments({otp: '123456'}), + ['publish', '--otp', '123456'], + ); +}); + +test('options.publishScoped', t => { + t.deepEqual( + getPackagePublishArguments({publishScoped: true}), + ['publish', '--access', 'public'], + ); +}); + +test('options.provenance', t => { + t.deepEqual( + getPackagePublishArguments({provenance: true}), + ['publish', '--provenance'], + ); +}); + +test('runPublish uses cwd option when provided', async t => { + const observable = runPublish(['echo', ['test']], {cwd: '/tmp'}); + // Should complete successfully + await t.notThrowsAsync(firstValueFrom(observable)); +}); + +test('runPublish returns an Observable that completes successfully', async t => { + const observable = runPublish(['echo', ['test']]); + t.not(observable, undefined); + // Process should complete successfully with our default options + await t.notThrowsAsync(firstValueFrom(observable)); +}); diff --git a/test/npm/util/check-connection.js b/test/npm/util/check-connection.js new file mode 100644 index 00000000..7a6480e8 --- /dev/null +++ b/test/npm/util/check-connection.js @@ -0,0 +1,34 @@ +import test from 'ava'; +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, + options: {timeout: 15_000}, +}], 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', createFixture, [{ + command: 'npm ping', + exitCode: 1, + timedOut: true, +}], async ({t, testedModule: npm}) => { + 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..000ec91e --- /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: (...arguments_) => output = arguments_.join('')}}, // eslint-disable-line no-return-assign + }); + + const fixtureDirectory = path.resolve('test/fixtures/files', fixture); + const package_ = files ? {files} : {}; + + await checkIgnoreStrategy(package_, fixtureDirectory); + + 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..e1f58eed --- /dev/null +++ b/test/npm/util/collaborators.js @@ -0,0 +1,86 @@ +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('package.name not a string', async t => { + await t.throwsAsync( + npm.collaborators({name: 1}), + {message: 'Package name is required'}, + ); +}); + +const accessCommand = (name = 'np') => `npm access list collaborators ${name} --json`; + +const collaboratorsStdout = stripIndent` + { + "sindresorhus": "read-write", + "samverschueren": "read-write", + "itaisteinherz": "read-write" + } +`; + +test('main', createFixture, [{ + command: accessCommand(), + stdout: collaboratorsStdout, +}], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({name: 'np'}), + collaboratorsStdout, + ); +}); + +// TODO: this is timing out, seemingly the command isn't matching for Sinon +// eslint-disable-next-line ava/no-skip-test +test.skip('external registry', createFixture, [{ + command: `${accessCommand()} --registry http://my-internal-registry.local`, + stdout: collaboratorsStdout, +}], async ({t, testedModule: {collaborators}}) => { + const output = await collaborators({ + name: 'np', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }); + + t.deepEqual( + JSON.parse(output), + JSON.parse(collaboratorsStdout), + ); +}); + +test('non-existent', createFixture, [{ + command: accessCommand('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('error on default registry', createFixture, [{ + command: accessCommand('@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'); +}); + +test('error on external registry - returns false', createFixture, [{ + command: `${accessCommand('@private/pkg')} --registry http://my-internal-registry.local`, + stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', +}], async ({t, testedModule: {collaborators}}) => { + // Errors should return false instead of throwing, since external registries + // often don't support the collaborators endpoint. + // See: https://github.com/sindresorhus/np/issues/420 + t.is(await collaborators({ + name: '@private/pkg', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), false); +}); diff --git a/test/npm/util/entry-points.js b/test/npm/util/entry-points.js new file mode 100644 index 00000000..f80347c3 --- /dev/null +++ b/test/npm/util/entry-points.js @@ -0,0 +1,204 @@ +import path from 'node:path'; +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +const getFixture = name => path.resolve('test', 'fixtures', 'files', name); + +test('getPackageEntryPoints - main', t => { + t.deepEqual( + npm.getPackageEntryPoints({main: 'index.js'}), + [{field: 'main', path: 'index.js'}], + ); + + t.deepEqual( + npm.getPackageEntryPoints({main: './dist/index.js'}), + [{field: 'main', path: './dist/index.js'}], + ); +}); + +test('getPackageEntryPoints - bin as string', t => { + t.deepEqual( + npm.getPackageEntryPoints({name: 'my-cli', bin: './cli.js'}), + [{field: 'bin', path: './cli.js'}], + ); +}); + +test('getPackageEntryPoints - bin as object', t => { + t.deepEqual( + npm.getPackageEntryPoints({bin: {foo: './bin/foo.js', bar: './bin/bar.js'}}), + [ + {field: 'bin.foo', path: './bin/foo.js'}, + {field: 'bin.bar', path: './bin/bar.js'}, + ], + ); +}); + +test('getPackageEntryPoints - exports as string', t => { + t.deepEqual( + npm.getPackageEntryPoints({exports: './index.js'}), + [{field: 'exports', path: './index.js'}], + ); +}); + +test('getPackageEntryPoints - exports with subpaths', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './foo': './foo.js', + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + {field: 'exports', path: './foo.js'}, + ]); +}); + +test('getPackageEntryPoints - exports with conditions', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': { + import: './index.mjs', + require: './index.cjs', + }, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.mjs'}, + {field: 'exports', path: './index.cjs'}, + ]); +}); + +test('getPackageEntryPoints - exports with nested conditions', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': { + node: { + import: './index.node.mjs', + require: './index.node.cjs', + }, + default: './index.js', + }, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.node.mjs'}, + {field: 'exports', path: './index.node.cjs'}, + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - combined main, bin, and exports', t => { + const entryPoints = npm.getPackageEntryPoints({ + main: './index.js', + bin: './cli.js', + exports: { + '.': './index.js', + './cli': './cli.js', + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'main', path: './index.js'}, + {field: 'bin', path: './cli.js'}, + {field: 'exports', path: './index.js'}, + {field: 'exports', path: './cli.js'}, + ]); +}); + +test('getPackageEntryPoints - empty package', t => { + t.deepEqual(npm.getPackageEntryPoints({}), []); + t.deepEqual(npm.getPackageEntryPoints({name: 'foo', version: '1.0.0'}), []); +}); + +test('getPackageEntryPoints - exports with wildcard patterns are skipped', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './features/*.js': './src/features/*.js', + './utils/*': './src/utils/*', + }, + }); + + // Only non-wildcard exports should be included + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - exports with null values are skipped', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './internal/*': null, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - invalid main values are skipped', t => { + t.deepEqual(npm.getPackageEntryPoints({main: null}), []); + t.deepEqual(npm.getPackageEntryPoints({main: 123}), []); + t.deepEqual(npm.getPackageEntryPoints({main: {}}), []); +}); + +test('getPackageEntryPoints - invalid bin values are skipped', t => { + t.deepEqual(npm.getPackageEntryPoints({bin: null}), []); + t.deepEqual(npm.getPackageEntryPoints({bin: 123}), []); + t.deepEqual(npm.getPackageEntryPoints({bin: {foo: null, bar: './bar.js'}}), [ + {field: 'bin.bar', path: './bar.js'}, + ]); +}); + +test('getPackageEntryPoints - duplicate paths from main and exports', t => { + const entryPoints = npm.getPackageEntryPoints({ + main: './index.js', + exports: './index.js', + }); + + // Both are returned (deduplication happens in verifyPackageEntryPoints) + t.deepEqual(entryPoints, [ + {field: 'main', path: './index.js'}, + {field: 'exports', path: './index.js'}, + ]); +}); + +test('verifyPackageEntryPoints - missing main', async t => { + const fixtureDirectory = getFixture('missing-main'); + + await t.throwsAsync( + npm.verifyPackageEntryPoints({main: 'dist/index.js'}, fixtureDirectory), + {message: /Missing entry points.*"main": dist\/index\.js/s}, + ); +}); + +test('verifyPackageEntryPoints - missing bin', async t => { + const fixtureDirectory = getFixture('missing-bin'); + + await t.throwsAsync( + npm.verifyPackageEntryPoints({bin: './cli.js'}, fixtureDirectory), + {message: /Missing entry points.*"bin": \.\/cli\.js/s}, + ); +}); + +test('verifyPackageEntryPoints - valid entry points', async t => { + const fixtureDirectory = getFixture('one-file'); + + await t.notThrowsAsync(npm.verifyPackageEntryPoints({main: 'index.js'}, fixtureDirectory)); +}); + +test('verifyPackageEntryPoints - skipped when prepack script may generate files', async t => { + const fixtureDirectory = getFixture('prepack-generated-entry-point'); + + await t.notThrowsAsync(npm.verifyPackageEntryPoints({ + main: 'dist/index.js', + scripts: { + prepack: 'build', + }, + }, fixtureDirectory)); +}); diff --git a/test/npm/util/is-external-registry.js b/test/npm/util/is-external-registry.js new file mode 100644 index 00000000..f24af402 --- /dev/null +++ b/test/npm/util/is-external-registry.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +test('main', t => { + t.true(npm.isExternalRegistry({publishConfig: {registry: 'https://my-internal-registry.local'}})); + + t.false(npm.isExternalRegistry({name: 'foo'})); + t.false(npm.isExternalRegistry({publishConfig: {registry: true}})); + t.false(npm.isExternalRegistry({publishConfig: 'not an object'})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org'}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org/'}})); + + // Test normalization: whitespace trimming + t.false(npm.isExternalRegistry({publishConfig: {registry: ' https://registry.npmjs.org '}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: ' https://registry.npmjs.org/ '}})); + + // Test normalization: http variant + t.false(npm.isExternalRegistry({publishConfig: {registry: 'http://registry.npmjs.org'}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'http://registry.npmjs.org/'}})); +}); 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..ecb28a6c --- /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 package_ = isExternalRegistry + ? {name, publishConfig: {registry: externalRegistry}} + : {name}; + + const availability = await npm.isPackageNameAvailable(package_); + 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/util/login.js b/test/npm/util/login.js new file mode 100644 index 00000000..e5b7d1a5 --- /dev/null +++ b/test/npm/util/login.js @@ -0,0 +1,27 @@ +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 login', + stdout: '', +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync(npm.login({})); +}); + +test('--registry flag', createFixture, [{ + command: 'npm login --registry http://my.io', + stdout: '', +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync(npm.login({externalRegistry: 'http://my.io'})); +}); + +test('fails if login fails', createFixture, [{ + command: 'npm login', + exitCode: 1, + stderr: 'npm ERR! Login failed', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync(npm.login({})); +}); diff --git a/test/npm/util/packed-files.js b/test/npm/util/packed-files.js new file mode 100644 index 00000000..5924a69b --- /dev/null +++ b/test/npm/util/packed-files.js @@ -0,0 +1,80 @@ +import path from 'node:path'; +import test from 'ava'; +import {getFilesToBePacked} from '../../../source/npm/util.js'; +import {runIfExists} from '../../_helpers/util.js'; + +const getFixture = name => path.resolve('test', 'fixtures', 'files', name); + +const verifyPackedFiles = test.macro(async (t, fixture, expectedFiles, {before, after} = {}) => { + const fixtureDirectory = getFixture(fixture); + + await runIfExists(before, fixtureDirectory); + t.teardown(async () => runIfExists(after, fixtureDirectory)); + + const files = await getFilesToBePacked(fixtureDirectory); + t.deepEqual(files.sort(), [...expectedFiles, 'package.json'].sort(), 'Files different from expectations!'); +}); + +test('package.json files field - one file', verifyPackedFiles, 'one-file', [ + 'index.js', +]); + +test('package.json files field - source dir', verifyPackedFiles, 'source-dir', [ + 'source/foo.js', + 'source/bar.js', +]); + +test('package.json files field - source and dist dirs', verifyPackedFiles, 'source-and-dist-dir', [ + 'source/foo.js', + 'source/bar.js', +]); + +test('package.json files field - leading slash', verifyPackedFiles, 'files-slash', [ + 'index.js', +]); + +test('package.json files field - has readme and license', verifyPackedFiles, 'has-readme-and-license', [ + 'readme.md', + 'license.md', + 'index.js', +]); + +test('npmignore', verifyPackedFiles, 'npmignore', [ + 'readme.md', + 'index.js', + 'index.d.ts', +]); + +test('package.json files field and npmignore', verifyPackedFiles, 'files-and-npmignore', [ + 'readme.md', + 'source/foo.js', + 'source/bar.js', + 'source/index.d.ts', +]); + +test('package.json files field and gitignore', verifyPackedFiles, 'gitignore', [ + 'readme.md', + 'dist/index.js', +]); + +test('npmignore and gitignore', verifyPackedFiles, 'npmignore-and-gitignore', [ + 'readme.md', + 'dist/index.js', +]); + +test('package.json main field not in files field', verifyPackedFiles, 'main', [ + 'foo.js', + 'bar.js', +]); + +test('doesn\'t show files in .github', verifyPackedFiles, 'dot-github', [ + 'index.js', +]); + +test('handles prepare script output (e.g., Husky)', verifyPackedFiles, 'prepare-script', [ + 'index.js', +]); + +test('ignores failing prepack script', verifyPackedFiles, 'failing-prepack-script', [ + 'index.js', +]); diff --git a/test/npm/util/prerelease-tags.js b/test/npm/util/prerelease-tags.js new file mode 100644 index 00000000..7a9a120e --- /dev/null +++ b/test/npm/util/prerelease-tags.js @@ -0,0 +1,115 @@ +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: 'Package name is required'}, + ); +}); + +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('non-existent with modern npm format (npm >=10) - should not throw', createFixture, [{ + command: 'npm view --json non-existent dist-tags', + stderr: stripIndent` + npm error code E404 + npm error 404 Not Found - GET https://registry.npmjs.org/non-existent - Not found + npm error 404 + npm error 404 The requested resource 'non-existent@*' could not be found or you do not have permission to access it. + npm error 404 + npm error 404 Note that you can also install from a + npm error 404 tarball, folder, http url, or git url. + { + "error": { + "code": "E404", + "summary": "Not Found - GET https://registry.npmjs.org/non-existent - Not found", + "detail": "The requested resource 'non-existent@*' could not be found." + } + } + npm error A complete log of this run can be found in: ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('non-existent'), + ['next'], + ); +}); + +test('bad permission (code 403) - should throw', createFixture, [{ + command: 'npm view --json @private/pkg dist-tags', + stderr: stripIndent` + npm ERR! code E403 + npm ERR! 403 403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden + npm ERR! 403 In most cases, you or one of your dependencies are requesting + npm ERR! 403 a package version that is forbidden by your security policy, or + npm ERR! 403 on a server you do not have access to. + { + "error": { + "code": "E403", + "summary": "403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden", + "detail": "In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to." + } + } + npm ERR! A complete log of this run can be found in: + npm ERR! ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + const error = await t.throwsAsync(prereleaseTags('@private/pkg')); + t.true(error.stderr?.includes('E403')); +}); diff --git a/test/npm/util/username.js b/test/npm/util/username.js new file mode 100644 index 00000000..f1bfc0c7 --- /dev/null +++ b/test/npm/util/username.js @@ -0,0 +1,52 @@ +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 - ENEEDAUTH', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! code ENEEDAUTH', +}], async ({t, testedModule: npm}) => { + const error = await t.throwsAsync( + npm.username({}), + {message: 'You must be logged in. Use `npm login` and try again.'}, + ); + t.true(error.isNotLoggedIn); +}); + +test('fails if not logged in - E401', createFixture, [{ + command: 'npm whoami', + stderr: 'npm error code E401\nnpm error 401 Unauthorized', +}], async ({t, testedModule: npm}) => { + const error = await t.throwsAsync( + npm.username({}), + {message: 'You must be logged in. Use `npm login` and try again.'}, + ); + t.true(error.isNotLoggedIn); +}); + +test('fails with authentication error', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! OTP required for authentication', +}], async ({t, testedModule: npm}) => { + const error = await t.throwsAsync( + npm.username({}), + {message: 'Authentication error. Use `npm whoami` to troubleshoot.'}, + ); + t.false(error.isNotLoggedIn); +}); 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..06ed16da --- /dev/null +++ b/test/npm/util/verify-recent-npm-version.js @@ -0,0 +1,22 @@ +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: '99.20.0', // Higher than minimum +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync(npm.verifyRecentNpmVersion()); +}); + +test('not satisfied', createFixture, [{ + command: 'npm --version', + stdout: '5.18.0', // Lower than minimum +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.verifyRecentNpmVersion(), + {message: /`np` requires npm >=/}, + ); +}); diff --git a/test/package-manager.js b/test/package-manager.js new file mode 100644 index 00000000..b7e295c7 --- /dev/null +++ b/test/package-manager.js @@ -0,0 +1,253 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import test from 'ava'; +import {getPackageManagerConfig} from '../source/package-manager/index.js'; +import { + npmConfig, + yarnConfig, + yarnBerryConfig, + pnpmConfig, + bunConfig, +} from '../source/package-manager/configs.js'; + +test('detects npm from package-lock.json', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'package-lock.json'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, npmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects pnpm from pnpm-lock.yaml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'pnpm-lock.yaml'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from yarn.lock without .yarnrc.yml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'yarn.lock'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from yarn.lock with .yarnrc.yml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'yarn.lock'), ''); + fs.writeFileSync(path.join(temporaryDirectory, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'yarn@3.0.0', + }); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'yarn@1.22.0', + }); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects pnpm from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'pnpm', + version: '>=9', + }, + }, + }); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Bun from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'bun', + version: '^1.0.0', + }, + }, + }); + + t.is(config, bunConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'yarn', + version: '^4.0.0', + }, + }, + }); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'yarn', + version: '^1.22.0', + }, + }, + }); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects package manager from devEngines.packageManager array', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: [ + { + name: 'pnpm', + version: '>=9', + }, + { + name: 'npm', + onFail: 'warn', + }, + ], + }, + }); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('packageManager field takes precedence over devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'npm@10.0.0', + devEngines: { + packageManager: { + name: 'pnpm', + }, + }, + }); + + t.is(config, npmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws when devEngines.packageManager has no name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'pnpm-lock.yaml'), ''); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + version: '>=9', + }, + }, + }), { + message: 'Missing "name" property for "packageManager".', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws when devEngines.packageManager array entry has no name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: [ + { + version: '>=9', + }, + { + name: 'pnpm', + }, + ], + }, + }), { + message: 'Missing "name" property for "packageManager".', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws for invalid devEngines.packageManager name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'deno', + }, + }, + }), { + message: 'Invalid package manager: deno', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); diff --git a/test/prefix.js b/test/prefix.js deleted file mode 100644 index 6ede56a6..00000000 --- a/test/prefix.js +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'ava'; -import proxyquire from 'proxyquire'; -import {getTagVersionPrefix} from '../source/util'; - -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: 'Expected `options` to be of type `object` but received type `undefined`'}); - await t.throwsAsync(getTagVersionPrefix({}), {message: 'Expected object `options` to have keys `["yarn"]`'}); -}); - -test.serial('defaults to "v" when command fails', async t => { - proxyquire('../source/util', {execa: Promise.reject}); - t.is(await getTagVersionPrefix({yarn: true}), 'v'); -}); diff --git a/test/preid.js b/test/preid.js deleted file mode 100644 index 2bdf5295..00000000 --- a/test/preid.js +++ /dev/null @@ -1,12 +0,0 @@ -import test from 'ava'; -import {getPreReleasePrefix} from '../source/util'; - -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: 'Expected `options` to be of type `object` but received type `undefined`'}); - await t.throwsAsync(getPreReleasePrefix({}), {message: 'Expected object `options` to have keys `["yarn"]`'}); -}); diff --git a/test/prerequisite-tasks.js b/test/prerequisite-tasks.js deleted file mode 100644 index 7c42f8ee..00000000 --- a/test/prerequisite-tasks.js +++ /dev/null @@ -1,244 +0,0 @@ -import test from 'ava'; -import execaStub from 'execa_test_double'; -import mockery from 'mockery'; -import version from '../source/version'; -import {SilentRenderer} from './fixtures/listr-renderer'; - -let testedModule; - -const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -test.before(() => { - mockery.registerMock('execa', execaStub.execa); - mockery.enable({ - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - }); - testedModule = require('../source/prerequisite-tasks'); -}); - -test.beforeEach(() => { - execaStub.resetStub(); -}); - -test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - execaStub.createStub([{ - command: 'npm ping', - exitCode: 1, - exitCodeName: 'EPERM', - stdout: '', - stderr: 'failed' - }]); - await t.throwsAsync(run(testedModule('1.0.0', {name: 'test'}, {})), - {message: 'Connection to npm registry failed'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && task.hasFailed())); -}); - -test.serial('private package: should disable task pinging npm registry', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && !task.isEnabled())); -}); - -test.serial('external registry: should disable task pinging npm registry', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, - {yarn: false})); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && !task.isEnabled())); -}); - -test.serial('should fail when npm version does not match range in `package.json`', async t => { - execaStub.createStub([ - { - command: 'npm --version', - exitCode: 0, - stdout: '6.0.0' - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - const depRange = require('../package.json').engines.npm; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to npm${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check npm version' && task.hasFailed())); -}); - -test.serial('should fail when yarn version does not match range in `package.json`', async t => { - execaStub.createStub([ - { - command: 'yarn --version', - exitCode: 0, - stdout: '1.0.0' - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - const depRange = require('../package.json').engines.yarn; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), - {message: `Please upgrade to yarn${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check yarn version' && task.hasFailed())); -}); - -test.serial('should fail when user is not authenticated at npm registry', async t => { - execaStub.createStub([ - { - command: 'npm whoami', - exitCode: 0, - stdout: 'sindresorhus' - }, - { - command: 'npm access ls-collaborators test', - exitCode: 0, - stdout: '{"sindresorhus": "read"}' - } - ]); - process.env.NODE_ENV = 'P'; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'You do not have write permissions required to publish this package.'}); - process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && task.hasFailed())); -}); - -test.serial('should fail when user is not authenticated at external registry', async t => { - execaStub.createStub([ - { - 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"}' - } - ]); - process.env.NODE_ENV = 'P'; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), - {message: 'You do not have write permissions required to publish this package.'}); - process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && task.hasFailed())); -}); - -test.serial('private package: should disable task `verify user is authenticated`', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - process.env.NODE_ENV = 'P'; - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})); - process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && !task.isEnabled())); -}); - -test.serial('should fail when git version does not match range in `package.json`', async t => { - execaStub.createStub([ - { - command: 'git version', - exitCode: 0, - stdout: 'git version 1.0.0' - } - ]); - const depRange = require('../package.json').engines.git; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to git${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git version' && task.hasFailed())); -}); - -test.serial('should fail when git remote does not exists', async t => { - execaStub.createStub([ - { - command: 'git ls-remote origin HEAD', - exitCode: 1, - exitCodeName: 'EPERM', - stderr: 'not found' - } - ]); - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'not found'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git remote' && task.hasFailed())); -}); - -test.serial('should fail when version is invalid', async t => { - await t.throwsAsync(run(testedModule('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Validate version' && task.hasFailed())); -}); - -test.serial('should fail when version is lower as latest version', async t => { - await t.throwsAsync(run(testedModule('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`'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Validate version' && task.hasFailed())); -}); - -test.serial('should fail when prerelease version of public package without dist tag given', async t => { - await t.throwsAsync(run(testedModule('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: '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'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check for pre-release version' && task.hasFailed())); -}); - -test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('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 => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('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 => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: 'vvb' - } - ]); - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'Git tag `v2.0.0` already exists.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git tag existence' && task.hasFailed())); -}); - -test.serial('checks should pass', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false}))); -}); diff --git a/test/release-task-helper.js b/test/release-task-helper.js new file mode 100644 index 00000000..5fd3164b --- /dev/null +++ b/test/release-task-helper.js @@ -0,0 +1,174 @@ +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(), + clipboardy: { + write: 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: true, + generateReleaseNotes: 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, + }, +}); + +test('uses clipboard when URL is too long', async t => { + const repoUrl = 'https://github.com/sindresorhus/np'; + const clipboardStub = sinon.stub(); + const openStub = sinon.stub(); + const urlCalls = []; + + // Generate a very long release notes string that will exceed the URL limit + const longReleaseNotes = 'x'.repeat(8000); + + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: openStub, + clipboardy: { + write: clipboardStub, + }, + '../source/util.js': { + async getTagVersionPrefix() { + return 'v'; + }, + async getPreReleasePrefix() { + return ''; + }, + }, + 'new-github-release-url'(options_) { + urlCalls.push(options_); + // Generate a realistic URL + const baseUrl = `${options_.repoUrl}/releases/new`; + const parameters = new URLSearchParams({ + tag: options_.tag, + body: options_.body, + prerelease: options_.isPrerelease ? '1' : '0', + }); + return `${baseUrl}?${parameters.toString()}`; + }, + }); + + await releaseTaskHelper( + { + version: '1.1.0', + repoUrl, + releaseNotes: true, + generateReleaseNotes: () => longReleaseNotes, + }, + {version: '1.0.0'}, + ); + + // Should be called twice: once with long notes, once with placeholder + t.is(urlCalls.length, 2); + t.is(urlCalls[0].body, longReleaseNotes); + t.is(urlCalls[1].body, ''); + t.true(clipboardStub.calledOnce); + t.true(clipboardStub.calledWith(longReleaseNotes)); + t.true(openStub.calledOnce); +}); + +test('does not use clipboard when URL is short enough', async t => { + const repoUrl = 'https://github.com/sindresorhus/np'; + const clipboardStub = sinon.stub(); + const openStub = sinon.stub(); + const urlCalls = []; + + const shortReleaseNotes = 'Short release notes'; + + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: openStub, + clipboardy: { + write: clipboardStub, + }, + '../source/util.js': { + async getTagVersionPrefix() { + return 'v'; + }, + async getPreReleasePrefix() { + return ''; + }, + }, + 'new-github-release-url'(options_) { + urlCalls.push(options_); + // Generate a realistic URL + const baseUrl = `${options_.repoUrl}/releases/new`; + const parameters = new URLSearchParams({ + tag: options_.tag, + body: options_.body, + prerelease: options_.isPrerelease ? '1' : '0', + }); + return `${baseUrl}?${parameters.toString()}`; + }, + }); + + await releaseTaskHelper( + { + version: '1.1.0', + repoUrl, + releaseNotes: true, + generateReleaseNotes: () => shortReleaseNotes, + }, + {version: '1.0.0'}, + ); + + // Should be called only once with the short notes + t.is(urlCalls.length, 1); + t.is(urlCalls[0].body, shortReleaseNotes); + t.false(clipboardStub.called); + t.true(openStub.calledOnce); +}); diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js new file mode 100644 index 00000000..1990a363 --- /dev/null +++ b/test/tasks/git-tasks.js @@ -0,0 +1,278 @@ +import test from 'ava'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDoesntExist} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-tasks.js', import.meta.url); + +test.afterEach(() => { + SilentRenderer.clearTasks(); +}); + +test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'release'})), + {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should not fail when current branch not master and publishing from any branch permitted', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync(run(gitTasks({anyBranch: true}))); + + assertTaskDoesntExist(t, 'Check current branch'); +}); + +test.serial('should fail when local working tree modified', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: 'M source/git-tasks.js', + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); + + assertTaskFailed(t, 'Check local working tree'); +}); + +test.serial('should not fail when no remote set up', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for branch \'master\'', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync(run(gitTasks({branch: 'master'}))); +}); + +test.serial('should fail when remote history differs and changes are fetched', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('should fail when remote has unfetched changes', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync(run(gitTasks({branch: 'master'}))); +}); + +test.serial('preflight should validate remote before checking remote history', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git status --short --branch --porcelain', + stdout: '## master...origin/master', + }, + { + command: 'git config branch.master.remote', + stdout: 'origin', + }, + { + command: 'git ls-remote origin HEAD', + exitCode: 1, + stderr: 'fatal: could not read from remote repository', + }, +], async ({t, testedModule}) => { + await t.throwsAsync( + testedModule.verifyGitTasks({branch: 'master'}), + {message: 'Git fatal error: could not read from remote repository'}, + ); +}); + +test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [ + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git symbolic-ref --quiet HEAD', + exitCode: 1, + }, + { + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for HEAD', + }, +], async ({t, testedModule}) => { + await t.notThrowsAsync(testedModule.verifyGitTasks({anyBranch: true})); +}); + +test.serial('preflight should validate explicit remote without upstream', createFixture, [ + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git ls-remote upstream HEAD', + exitCode: 1, + stderr: 'fatal: remote upstream not found', + }, +], async ({t, testedModule}) => { + await t.throwsAsync( + testedModule.verifyGitTasks({anyBranch: true, remote: 'upstream'}), + {message: 'Git fatal error: remote upstream not found'}, + ); +}); + +test.serial('preflight should validate the tracked remote instead of origin', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'main', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git status --short --branch --porcelain', + stdout: '## main...upstream/main', + }, + { + command: 'git config branch.main.remote', + stdout: 'upstream', + }, + { + command: 'git ls-remote upstream HEAD', + exitCode: 0, + }, + { + 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}) => { + await t.notThrowsAsync(testedModule.verifyGitTasks({branch: 'main'})); +}); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js new file mode 100644 index 00000000..035e9d83 --- /dev/null +++ b/test/tasks/prerequisite-tasks.js @@ -0,0 +1,672 @@ +import process from 'node:process'; +import test from 'ava'; +import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; +import {npPackage} from '../../source/util.js'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import { + run, + assertTaskFailed, + assertTaskDisabled, + assertTaskSkipped, +} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/prerequisite-tasks.js', import.meta.url); + +test.beforeEach(() => { + process.env.NODE_ENV = 'test'; +}); + +test.afterEach(() => { + SilentRenderer.clearTasks(); + process.env.NODE_ENV = 'test'; +}); + +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'}, {}, {packageManager: npmConfig})), + {message: 'Connection to npm registry failed'}, + ); + + assertTaskFailed(t, 'Ping npm registry'); +}); + +test.serial('private package: should disable task pinging npm registry', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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}, {}, {packageManager: npmConfig}))); + + assertTaskDisabled(t, 'Ping npm registry'); +}); + +test.serial('external registry: should disable task pinging npm registry', createFixture, [{ + command: 'npm view --json test engines --registry http://my.io', + stdout: '', +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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'}}, {}, {packageManager: npmConfig}))); + + assertTaskDisabled(t, 'Ping npm registry'); +}); + +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 = npPackage.engines.npm; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: `\`np\` requires npm ${depRange}`}, + ); + + assertTaskFailed(t, 'Check npm version'); +}); + +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 = npPackage.engines.yarn; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: yarnConfig})), + {message: `\`np\` requires yarn ${depRange}`}, + ); + + assertTaskFailed(t, 'Check yarn version'); +}); + +test.serial('should fail when user is not authenticated at npm registry', createFixture, [ + { + command: 'npm whoami', + stdout: 'sindresorhus', + }, + { + command: 'npm access list collaborators test --json', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: 'You do not have write permissions required to publish this package.'}, + ); + + process.env.NODE_ENV = 'test'; + + assertTaskFailed(t, 'Verify user is authenticated'); +}); + +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 list collaborators test --json --registry http://my.io', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, {packageManager: npmConfig})), + {message: 'You do not have write permissions required to publish this package.'}, + ); + + process.env.NODE_ENV = 'test'; + + assertTaskFailed(t, 'Verify user is authenticated'); +}); + +test.serial('should use publishConfig.registry even when set to official npm registry', createFixture, [ + { + command: 'npm whoami --registry https://registry.npmjs.org/', + stdout: 'sindresorhus', + }, + { + command: 'npm access list collaborators test --json --registry https://registry.npmjs.org/', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'https://registry.npmjs.org/'}}, {}, {packageManager: npmConfig})), + {message: 'You do not have write permissions required to publish this package.'}, + ); + + process.env.NODE_ENV = 'test'; + + 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 config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, {packageManager: npmConfig}))); + + process.env.NODE_ENV = 'test'; + + assertTaskDisabled(t, 'Verify user is authenticated'); +}); + +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 = npPackage.engines.git; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: `\`np\` requires git ${depRange}`}, + ); + + assertTaskFailed(t, 'Check git version'); +}); + +test.serial('should fail when git user.name is not set', createFixture, [{ + command: 'git config user.name', + exitCode: 1, + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: /Git user configuration is not set/}, + ); + + assertTaskFailed(t, 'Check git user configuration'); +}); + +test.serial('should fail when git user.email is not set', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + exitCode: 1, + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: /Git user configuration is not set/}, + ); + + assertTaskFailed(t, 'Check git user configuration'); +}); + +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'}, {}, {packageManager: npmConfig})), + {message: 'not found'}, + ); + + assertTaskFailed(t, 'Check git remote'); +}); + +test.serial('should fail when version is invalid', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, + ); + + assertTaskFailed(t, 'Validate version'); +}); + +test.serial('should fail when version is lower than latest version', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, + ); + + assertTaskFailed(t, 'Validate version'); +}); + +test.serial('should fail when prerelease version of public package without dist tag given', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), + {message: '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'}, + ); + + assertTaskFailed(t, 'Check for pre-release version'); +}); + +test.serial('should not fail when prerelease version of public package with dist tag given', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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'}, {tag: 'pre'}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should fail when git tag already exists', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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'}, {}, {packageManager: npmConfig})), + {message: 'Git tag `v2.0.0` already exists.'}, + ); + + assertTaskFailed(t, 'Check git tag existence'); +}); + +test.serial('checks should pass', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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'}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should skip authentication check when OIDC is detected', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + process.env.GITHUB_ACTIONS = 'true'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'url'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'token'; + + t.teardown(() => { + delete process.env.GITHUB_ACTIONS; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + process.env.NODE_ENV = 'test'; + }); + + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig}))); + + assertTaskSkipped(t, 'Verify user is authenticated'); +}); + +test.serial('should fail when dropping Node.js support in a minor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a minor bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a patch release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.1', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.1', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a patch bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should not fail when dropping Node.js support in a major release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + 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', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when dropping Node.js support in a premajor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when dropping Node.js support in a pre-1.0.0 minor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v0.2.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('0.2.0', {name: 'test', version: '0.1.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when engines.node was not previously set', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when first publishing a package', createFixture, [{ + command: 'npm view --json test engines', + exitCode: 1, + stderr: 'E404 Not Found', +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.0.0', {name: 'test', version: '0.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when local package has no engines.node', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig}))); +}); + +test.serial('private package: should disable task checking for Node.js engine support drop', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + private: true, + engines: {node: '>=18'}, + }; + + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, {packageManager: npmConfig}))); + + assertTaskDisabled(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should not fail when Node.js minimum version stays the same', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=18'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should not fail when Node.js minimum version is lowered', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=18'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=16'}}, {}, {packageManager: npmConfig}))); +}); + +test.serial('should fail when dropping Node.js support in a preminor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.1.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a preminor bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a prepatch release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.1-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.1-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prepatch bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a prerelease release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.0-1', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('yolo mode: should disable task checking for Node.js engine support drop', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + engines: {node: '>=18'}, + }; + + // Should not throw even though we're dropping Node.js support in a minor release, + // because yolo mode skips this check + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {yolo: true}, {packageManager: npmConfig}))); + + assertTaskDisabled(t, 'Check for Node.js engine support drop'); +}); + +test.serial('external registry: should skip engine check when registry returns error', createFixture, [{ + command: 'npm view --json test engines --registry http://my.io', + exitCode: 1, + stderr: 'E405 Method Not Allowed', +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + engines: {node: '>=18'}, + publishConfig: {registry: 'http://my.io'}, + }; + + // Should not throw even though we're dropping Node.js support in a minor release, + // because the external registry doesn't support the npm view endpoint + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, {packageManager: npmConfig}))); +}); diff --git a/test/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts new file mode 100644 index 00000000..b4920e51 --- /dev/null +++ b/test/ui/new-files-dependencies.d.ts @@ -0,0 +1,34 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {PackageJson} from 'read-pkg'; + +type Context = { + createFile: (file: string, content?: string) => Promise; +}; + +type CommandsFunctionParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; +}]; + +type ListItem = `- ${string}`; + +type Expected = { + unpublished: ListItem[]; + firstTime: ListItem[]; + dependencies: ListItem[]; +}; + +type AssertionsFunctionParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; + logs: string[]; +}]; + +export type CreateFixtureMacro = Macro<[ + package_: PackageJson, + commands: (...arguments_: CommandsFunctionParameters) => Promise, + expected: Expected, + assertions: (...arguments_: AssertionsFunctionParameters) => Promise, +], Context>; diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js new file mode 100644 index 00000000..d7736968 --- /dev/null +++ b/test/ui/new-files-dependencies.js @@ -0,0 +1,129 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {execa} from 'execa'; +import {removePackageDependencies, updatePackage} from 'write-package'; +import stripAnsi from 'strip-ansi'; +import {readPackage} from 'read-pkg'; +import {npmConfig as packageManager} from '../../source/package-manager/configs.js'; +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('⚠ WARNING: The following new files will NOT be published:'); +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, package_, commands, expected) => { + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { + package_ = { + name: '@np/foo', + version: '0.0.0', + dependencies: {}, + ...package_, + }; + + await updatePackage(temporaryDirectory, package_); + + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + + await commands({t, $$, temporaryDirectory}); + package_ = await readPackage({cwd: temporaryDirectory}); + + const {ui, logs: logsArray} = await mockInquirer({ + t, answers: {confirm: {confirm: false}}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, + 'is-interactive': () => false, + }, + }); + + await ui({runPublish: true, version: 'major', packageManager}, {package_, rootDirectory: temporaryDirectory}); + const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); + + const {unpublished, firstTime, dependencies} = expected; + + const assertions = await t.try(tt => { + if (unpublished) { + checkNewUnpublished(tt, logs, unpublished); + } + + if (firstTime) { + checkFirstTimeFiles(tt, logs, firstTime); + } + + if (dependencies) { + checkNewDependencies(tt, logs, dependencies); + } + }); + + if (!assertions.passed) { + t.log('logs:', logs); + t.log('package:', package_); + t.log('expected:', expected); + } + + assertions.commit(); + }); +}); + +test('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new']}); + +test('unpublished and first time', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new'], firstTime: ['- index.js']}); + +test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDirectory}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], dependencies: ['- cat-names']}); + +test('first time', createFixture, {}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {firstTime: ['- new']}); + +test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDirectory}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, {firstTime: ['- new'], dependencies: ['- cat-names']}); + +test('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDirectory}) => { + await removePackageDependencies(temporaryDirectory, ['dog-names']); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, {dependencies: ['- cat-names']}); + +test('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDirectory}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDirectory, {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..5c7446be --- /dev/null +++ b/test/ui/prompts/tags.js @@ -0,0 +1,183 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../../source/package-manager/configs.js'; +import {npPackage} from '../../../source/util.js'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { + const {ui, logs} = await mockInquirer({ + t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(tags), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPackage.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + './package-manager/index.js': { + findLockfile: sinon.stub().resolves(undefined), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: true, + availability: {}, + }, { + package_: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose next', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + prereleasePrefix: '', + tag: 'next', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'next'); +}); + +test('choose beta', testUi, { + version: '0.0.0', + tags: ['beta', 'stable'], + answers: { + version: 'prerelease', + prereleasePrefix: '', + tag: 'beta', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'beta'); +}); + +test('choose custom', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + prereleasePrefix: '', + tag: 'Other (specify)', + customTag: 'alpha', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +test('choose custom - validation', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + prereleasePrefix: '', + tag: 'Other (specify)', + customTag: [ + { + input: '', + error: 'Please specify a tag, for example, `next`.', + }, + { + input: 'latest', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'LAteSt', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'alpha', + }, + ], + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +// Assuming from version 0.0.0 +const fixtures = [ + {version: 'premajor', expected: '1.0.0-0'}, + {version: 'preminor', expected: '0.1.0-0'}, + {version: 'prepatch', expected: '0.0.1-0'}, + {version: 'prerelease', expected: '0.0.1-0'}, +]; + +for (const {version, expected} of fixtures) { + test(`works for ${version}`, testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version, + prereleasePrefix: '', + tag: 'next', + }, + }, ({t, results: {version, tag}}) => { + t.is(version.toString(), expected); + t.is(tag, 'next'); + }); +} + +// Test that prerelease versions provided via CLI prompt for tag +for (const {version} of fixtures) { + test(`prompts for tag when ${version} is provided via CLI`, async t => { + const {ui} = await mockInquirer({ + t, answers: {tag: 'next', confirm: true}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(['next']), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPackage.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + './package-manager/index.js': { + findLockfile: sinon.stub().resolves(undefined), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: true, + availability: {}, + version, + }, { + package_: { + name: 'foo', + version: '0.0.0', + files: ['*'], + }, + }); + + // Verify that the tag was set via prompt + t.is(results.tag, 'next'); + }); +} diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js new file mode 100644 index 00000000..a5c872f7 --- /dev/null +++ b/test/ui/prompts/version.js @@ -0,0 +1,286 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../../source/package-manager/configs.js'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, answers}, assertions) => { + const {ui, logs} = await mockInquirer({ + t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: false, + availability: {}, + }, { + package_: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose major', testUi, { + version: '0.0.0', + answers: { + version: 'major', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose minor', testUi, { + version: '0.0.0', answers: { + version: 'minor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0'); +}); + +test('choose patch', testUi, { + version: '0.0.0', answers: { + version: 'patch', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1'); +}); + +test('choose premajor', testUi, { + version: '0.0.0', answers: { + version: 'premajor', + prereleasePrefix: '', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-0'); +}); + +test('choose preminor', testUi, { + version: '0.0.0', answers: { + version: 'preminor', + prereleasePrefix: '', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0-0'); +}); + +test('choose prepatch', testUi, { + version: '0.0.0', answers: { + version: 'prepatch', + prereleasePrefix: '', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-0'); +}); + +test('choose prerelease', testUi, { + version: '0.0.1-0', answers: { + version: 'prerelease', + prereleasePrefix: '', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-1'); +}); + +test('choose custom', testUi, { + version: '0.0.0', answers: { + version: 'Other (specify)', + customVersion: '1.0.0', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose custom - validation', testUi, { + version: '1.0.0', answers: { + version: 'Other (specify)', + customVersion: [ + { + input: 'major', + error: 'Custom version should not be a SemVer increment.', + }, + { + input: '200', + error: 'Custom version 200 should be a valid SemVer version.', + }, + { + input: '0.0.0', + error: 'Custom version 0.0.0 should be higher than current version 1.0.0.', + }, + { + input: '1.0.0', + error: 'Custom version 1.0.0 should be higher than current version 1.0.0.', + }, + { + input: '2.0.0', + }, + ], + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '2.0.0'); +}); + +test('choose prepatch with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'prepatch', + prereleasePrefix: 'beta', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.1-beta.0'); +}); + +test('choose preminor with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'preminor', + prereleasePrefix: 'alpha', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.1.0-alpha.0'); +}); + +test('choose premajor with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'premajor', + prereleasePrefix: 'rc', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '2.0.0-rc.0'); +}); + +test('choose prerelease with custom prerelease identifier', testUi, { + version: '1.0.0-rc.0', + answers: { + version: 'prerelease', + prereleasePrefix: 'rc', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-rc.1'); +}); + +test('uses current prerelease identifier when available', testUi, { + version: '1.0.2-beta.3', // Current version has 'beta' identifier + answers: { + version: 'prerelease', + prereleasePrefix: 'beta', // Accept the default which should be 'beta' + }, +}, ({t, results: {version}}) => { + // Should use 'beta' from current version + t.is(version.toString(), '1.0.2-beta.4'); +}); + +test('handles numeric prerelease identifiers', testUi, { + version: '1.0.0-0', // Numeric prerelease identifier + answers: { + version: 'prerelease', + prereleasePrefix: 'beta', // Should not suggest '0', user enters 'beta' + }, +}, ({t, results: {version}}) => { + // Should transition from numeric to string identifier + t.is(version.toString(), '1.0.0-beta.0'); +}); + +test('releaseDraftOnly does not throw on current version', async t => { + const {ui} = await mockInquirer({ + t, answers: {}, mocks: { + './util.js': { + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + previousTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: false, + availability: {}, + releaseDraftOnly: true, + version: '1.0.0', + }, { + package_: { + name: 'foo', + version: '1.0.0', + repository: {url: 'https://github.com/foo/bar'}, + }, + }); + + t.true(results.confirm); + t.is(results.version, '1.0.0'); +}); + +test('uses empty prerelease identifier by default when no prerelease identifier is configured', async t => { + let capturedPrereleasePrefixPrompt; + + const {ui} = await mockInquirer({ + t, + answers: { + version: 'premajor', + prereleasePrefix: '', + }, + onPrompt(prompts) { + capturedPrereleasePrefixPrompt = prompts.prereleasePrefix; + }, + mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves('abc 123'), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const {version} = await ui({ + packageManager, + runPublish: false, + availability: {}, + }, { + package_: { + name: 'foo', + version: '1.0.0', + files: ['*'], + repository: { + url: 'https://github.com/foo/bar', + }, + }, + }); + + t.truthy(capturedPrereleasePrefixPrompt); + t.is(capturedPrereleasePrefixPrompt.default, '', 'the prerelease identifier default should remain empty'); + t.is(version.toString(), '2.0.0-0'); +}); diff --git a/test/ui/repo-url.js b/test/ui/repo-url.js new file mode 100644 index 00000000..6d33f152 --- /dev/null +++ b/test/ui/repo-url.js @@ -0,0 +1,47 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../source/package-manager/configs.js'; +import {mockInquirer} from '../_helpers/mock-inquirer.js'; + +test('strips committish from hosted git info browse url', async t => { + const {ui} = await mockInquirer({ + t, + answers: {confirm: true}, + mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: false, + availability: {}, + version: 'patch', + }, { + package_: { + name: 'foo', + version: '1.0.0', + files: ['*'], + repository: { + url: 'git+https://github.com/org/repo.git#main', + }, + }, + rootDirectory: '/tmp', + }); + + t.is(results.repoUrl, 'https://github.com/org/repo'); +}); diff --git a/test/util/auto-group-list.js b/test/util/auto-group-list.js new file mode 100644 index 00000000..331d1b28 --- /dev/null +++ b/test/util/auto-group-list.js @@ -0,0 +1,33 @@ +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {groupFilesInFolders} from '../../source/util.js'; + +const testJoinList = test.macro((t, {list, expected}) => { + const output = groupFilesInFolders(list); + t.is(stripAnsi(output), expected); +}); + +test('one item', testJoinList, { + list: [ + 'scripts/a.sh', + ], + expected: '- scripts/a.sh', +}); + +test('mix of collapsed and expanded folders', testJoinList, { + list: [ + 'scripts/a.sh', + 'scripts/b.sh', + 'scripts/c.sh', + 'test/_utils-1.js', + 'test/_utils-2.js', + 'test/_utils-3.js', + 'test/_utils-4.js', + 'test/_utils-5.js', + 'test/_utils-6.js', + ], + expected: `- scripts/a.sh +- scripts/b.sh +- scripts/c.sh +- test/* (6 files)`, +}); diff --git a/test/util/get-minimum-node-version.js b/test/util/get-minimum-node-version.js new file mode 100644 index 00000000..c1bbff1e --- /dev/null +++ b/test/util/get-minimum-node-version.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {getMinimumNodeVersion} from '../../source/util.js'; + +test('returns minimum version from >=X range', t => { + t.is(getMinimumNodeVersion('>=18'), '18.0.0'); + t.is(getMinimumNodeVersion('>=18.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16.14.0'), '16.14.0'); +}); + +test('returns minimum version from ^X range', t => { + t.is(getMinimumNodeVersion('^18'), '18.0.0'); + t.is(getMinimumNodeVersion('^18.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('^16.14.0'), '16.14.0'); +}); + +test('returns minimum version from X range', t => { + t.is(getMinimumNodeVersion('18'), '18.0.0'); + t.is(getMinimumNodeVersion('18.0.0'), '18.0.0'); +}); + +test('returns minimum version from OR range', t => { + t.is(getMinimumNodeVersion('18 || 20'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16 || >=18'), '16.0.0'); +}); + +test('returns minimum version from complex range', t => { + t.is(getMinimumNodeVersion('>=18.0.0 <20.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16.14.0 <17.0.0 || >=18.0.0'), '16.14.0'); +}); + +test('returns undefined for invalid input', t => { + t.is(getMinimumNodeVersion(undefined), undefined); + t.is(getMinimumNodeVersion(null), undefined); + t.is(getMinimumNodeVersion(''), undefined); + t.is(getMinimumNodeVersion(123), undefined); +}); + +test('returns undefined for invalid range', t => { + t.is(getMinimumNodeVersion('invalid'), undefined); +}); diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js new file mode 100644 index 00000000..bff15cdc --- /dev/null +++ b/test/util/get-new-dependencies.js @@ -0,0 +1,44 @@ +import test from 'ava'; +import {updatePackage} from 'write-package'; +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 ({$$, temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {dependencies: {'dog-names': '^2.1.0'}}); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); + + t.deepEqual( + await getNewDependencies(package_, temporaryDirectory), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release)', createFixture, async ({temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); + + t.deepEqual( + await getNewDependencies(package_, temporaryDirectory), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release) - no deps', createFixture, async ({temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {name: '@np/foo'}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); + + t.deepEqual( + await getNewDependencies(package_, temporaryDirectory), + [], + ); +}); diff --git a/test/util/get-new-files.js b/test/util/get-new-files.js new file mode 100644 index 00000000..37f3bb3e --- /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-package'; +import {createIntegrationTest} from '../_helpers/integration-test.js'; + +const createNewFilesFixture = test.macro(async (t, input, commands) => { + const {packageFiles, expected: {unpublished, firstTime}} = input; + + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { + /** @type {import('../../source/util.js')} */ + const {getNewFiles} = await esmock('../../source/util.js', {}, { + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, + }); + + await commands({t, $$, temporaryDirectory}); + + await writePackage(temporaryDirectory, { + name: 'foo', + version: '0.0.0', + ...packageFiles.length > 0 ? {files: packageFiles} : {}, + }); + + const assertions = await t.try(async tt => { + tt.deepEqual( + await getNewFiles(temporaryDirectory), + {unpublished, firstTime}, + ); + }); + + if (!assertions.passed) { + t.log(input); + } + + assertions.commit(); + }); +}); + +test('files to package with tags added', createNewFilesFixture, { + packageFiles: ['*.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, { + packageFiles: ['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, { + packageFiles: ['*.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, { + packageFiles: [], + expected: { + unpublished: [], + firstTime: [], + }, +}, async ({$$}) => { + await $$`git tag v0.0.0`; +}); + +test('ignores .git and .github files', createNewFilesFixture, { + packageFiles: ['*.js'], + expected: { + unpublished: [], + firstTime: ['index.js'], + }, +}, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('.github/workflows/main.yml'); + await t.context.createFile('.github/pull_request_template.md'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}); diff --git a/test/util/get-npm-package-access.js b/test/util/get-npm-package-access.js new file mode 100644 index 00000000..87ba7465 --- /dev/null +++ b/test/util/get-npm-package-access.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('main', createFixture, [{ + command: 'npm access get status @my/pkg --json', + stdout: '{"@my/pkg": "public"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({name: '@my/pkg'}), + 'public', + ); +}); + +test('with publishConfig.registry', createFixture, [{ + command: 'npm access get status @my/pkg --json --registry https://registry.npmjs.org/', + stdout: '{"@my/pkg": "public"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({ + name: '@my/pkg', + publishConfig: { + registry: 'https://registry.npmjs.org/', + }, + }), + 'public', + ); +}); + +test('with external registry', createFixture, [{ + command: 'npm access get status @my/pkg --json --registry http://my-internal-registry.local', + stdout: '{"@my/pkg": "private"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({ + name: '@my/pkg', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + 'private', + ); +}); diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js new file mode 100644 index 00000000..0a69d8d8 --- /dev/null +++ b/test/util/get-pre-release-prefix.js @@ -0,0 +1,68 @@ +import process from 'node:process'; +import test from 'ava'; +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({cli: 'npm'}), + 'pre', + ); +}); + +test('returns preid postfix if set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'pre', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({cli: 'yarn'}), + '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({cli: 'npm'}), + '', + ); +}); + +test('returns empty string if not set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'undefined', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({cli: 'yarn'}), + '', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetPreReleasePrefix(), + {message: 'Config is missing key `cli`'}, + ); + + await t.throwsAsync( + originalGetPreReleasePrefix({}), + {message: 'Config is missing key `cli`'}, + ); +}); + +test.serial('returns actual value', async t => { + const originalPreid = process.env.NPM_CONFIG_PREID; + process.env.NPM_CONFIG_PREID = 'beta'; + + t.is(await originalGetPreReleasePrefix({cli: 'npm'}), 'beta'); + + process.env.NPM_CONFIG_PREID = originalPreid; +}); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js new file mode 100644 index 00000000..a6ab1502 --- /dev/null +++ b/test/util/get-tag-version-prefix.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; +import {npmConfig, yarnConfig, pnpmConfig} from '../../source/package-manager/configs.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('returns tag prefix - npm', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(npmConfig), + 'ver', + ); +}); + +test('returns preId postfix - yarn', createFixture, [{ + command: 'yarn config get version-tag-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(yarnConfig), + 'ver', + ); +}); + +test('defaults to "v" when command fails', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + exitCode: 1, +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(npmConfig), + 'v', + ); +}); + +test('returns tag prefix - pnpm (uses npm config)', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + stdout: 'v', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'v', + ); +}); + +test('returns custom tag prefix - pnpm', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'ver', + ); +}); + +test('returns empty string tag prefix - pnpm', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + stdout: '', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + '', + ); +}); + +test('pnpm defaults to "v" when npm config fails', createFixture, [{ + command: 'npm config get tag-version-prefix --workspaces=false', + exitCode: 1, +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'v', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetTagVersionPrefix(), + {message: 'Config is missing key `tagVersionPrefixCommand`'}, + ); + + await t.throwsAsync( + originalGetTagVersionPrefix({}), + {message: 'Config is missing key `tagVersionPrefixCommand`'}, + ); +}); diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js new file mode 100644 index 00000000..fc6c72f9 --- /dev/null +++ b/test/util/hyperlinks.js @@ -0,0 +1,76 @@ +import test from 'ava'; +import esmock from 'esmock'; + +const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; +const MOCK_COMMIT_HASH = '5063f8a'; +const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; + +const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { + /** @type {typeof import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'supports-hyperlinks': { + stdout: linksSupported, + stderr: linksSupported, + }, + }); + + await assertions({t, util}); +}); + +test('linkifyIssues correctly links issues', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); +}); + +test('linkifyIssues returns raw message if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #5'; + t.is(linkifyIssues(undefined, message), message); +}); + +test('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #6'; + t.is(linkifyIssues(MOCK_REPO_URL, message), `${message} ${MOCK_REPO_URL}/issues/6`); +}); + +test('linkifyCommit correctly links commits', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); +}); + +test('linkifyCommit returns raw commit hash if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); +}); + +test('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), `${MOCK_COMMIT_HASH} ${MOCK_REPO_URL}/commit/${MOCK_COMMIT_HASH}`); +}); + +test('linkifyCommitRange returns raw commitRange if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); +}); + +test('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), `${MOCK_COMMIT_RANGE} ${MOCK_REPO_URL}/compare/${MOCK_COMMIT_RANGE}`); +}); + +test('linkifyCommitRange correctly links commit range', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), ']8;;https://github.com/unicorn/rainbow/compare/5063f8a...master5063f8a...master]8;;'); +}); diff --git a/test/util/join-list.js b/test/util/join-list.js new file mode 100644 index 00000000..3257adf9 --- /dev/null +++ b/test/util/join-list.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {joinList} from '../../source/util.js'; + +const testJoinList = test.macro((t, {list, expected}) => { + const output = joinList(list); + t.is(stripAnsi(output), expected); +}); + +test('one item', testJoinList, { + list: ['foo'], + expected: '- foo', +}); + +test('two items', testJoinList, { + list: ['foo', 'bar'], + expected: '- foo\n- bar', +}); + +test('multiple items', testJoinList, { + list: ['foo', 'bar', 'baz'], + expected: '- foo\n- bar\n- baz', +}); diff --git a/test/util/parse-git-url.js b/test/util/parse-git-url.js new file mode 100644 index 00000000..058b31e1 --- /dev/null +++ b/test/util/parse-git-url.js @@ -0,0 +1,658 @@ +import test from 'ava'; +import {parseGitUrl} from '../../source/util.js'; + +// Valid URL formats +test('parses HTTPS URL with .git suffix', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses HTTPS URL without .git suffix', t => { + t.is(parseGitUrl('https://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses HTTP URL with .git suffix', t => { + t.is(parseGitUrl('http://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses HTTP URL without .git suffix', t => { + t.is(parseGitUrl('http://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL (git@host:owner/repo.git)', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL without .git suffix', t => { + t.is(parseGitUrl('git@github.com:owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL with fragment', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git#main'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL with query', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git?ref=main'), 'https://github.com/owner/repo'); +}); + +test('parses git+https URL', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses git+https URL without .git suffix', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses git+https URL with fragment', t => { + t.is( + parseGitUrl('git+https://git.company.com/owner/repo.git#main'), + 'https://git.company.com/owner/repo', + ); +}); + +test('parses git+https URL with query and fragment without .git suffix', t => { + t.is( + parseGitUrl('git+https://git.company.com/owner/repo?ref=main#readme'), + 'https://git.company.com/owner/repo', + ); +}); + +test('parses ssh:// URL', t => { + t.is(parseGitUrl('ssh://git@github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses ssh:// URL without .git suffix', t => { + t.is(parseGitUrl('ssh://git@github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses ssh:// URL with query', t => { + t.is( + parseGitUrl('ssh://git@git.company.com/owner/repo.git?ref=main'), + 'https://git.company.com/owner/repo', + ); +}); + +test('parses ssh:// URL with fragment without .git suffix', t => { + t.is( + parseGitUrl('ssh://git@git.company.com/owner/repo#main'), + 'https://git.company.com/owner/repo', + ); +}); + +// GitHub Enterprise URLs +test('parses GitHub Enterprise HTTPS URL', t => { + t.is( + parseGitUrl('https://github.enterprise.com/org/project.git'), + 'https://github.enterprise.com/org/project', + ); +}); + +test('parses GitHub Enterprise SSH URL', t => { + t.is( + parseGitUrl('git@github.enterprise.com:org/project.git'), + 'https://github.enterprise.com/org/project', + ); +}); + +test('parses GitHub Enterprise SSH URL without .git suffix', t => { + t.is( + parseGitUrl('git@github.enterprise.com:org/project'), + 'https://github.enterprise.com/org/project', + ); +}); + +test('parses GitHub Enterprise with subdomain', t => { + t.is( + parseGitUrl('git@git.company.internal:team/repo.git'), + 'https://git.company.internal/team/repo', + ); +}); + +// Special characters in names +test('handles hyphens in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my-org/my-repo.git'), 'https://github.com/my-org/my-repo'); +}); + +test('handles underscores in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my_org/my_repo.git'), 'https://github.com/my_org/my_repo'); +}); + +test('handles dots in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my.org/my.repo.git'), 'https://github.com/my.org/my.repo'); +}); + +test('handles numbers in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/org123/repo456.git'), 'https://github.com/org123/repo456'); +}); + +test('handles mixed special characters', t => { + t.is( + parseGitUrl('https://github.com/my-org_123/my-repo_456.git'), + 'https://github.com/my-org_123/my-repo_456', + ); +}); + +// Ports in URLs +test('handles HTTPS URL with port', t => { + t.is( + parseGitUrl('https://github.com:443/owner/repo.git'), + 'https://github.com:443/owner/repo', + ); +}); + +test('handles git+https URL with port', t => { + t.is( + parseGitUrl('git+https://github.com:443/owner/repo.git'), + 'https://github.com:443/owner/repo', + ); +}); + +test('handles ssh:// URL with port', t => { + t.is( + parseGitUrl('ssh://git@github.com:2222/owner/repo.git'), + 'https://github.com:2222/owner/repo', + ); +}); + +test('handles HTTP URL with custom port', t => { + t.is( + parseGitUrl('http://git.company.com:8080/team/project.git'), + 'https://git.company.com:8080/team/project', + ); +}); + +// Edge cases - invalid inputs +test('returns undefined for empty string', t => { + t.is(parseGitUrl(''), undefined); +}); + +test('returns undefined for non-string input (null)', t => { + t.is(parseGitUrl(null), undefined); +}); + +test('returns undefined for non-string input (undefined)', t => { + t.is(parseGitUrl(undefined), undefined); +}); + +test('returns undefined for non-string input (number)', t => { + t.is(parseGitUrl(123), undefined); +}); + +test('returns undefined for non-string input (object)', t => { + t.is(parseGitUrl({}), undefined); +}); + +// Malformed URLs +test('returns undefined for URL with extra slashes', t => { + t.is(parseGitUrl('https://github.com//owner//repo.git'), undefined); +}); + +test('returns undefined for URL with missing owner', t => { + t.is(parseGitUrl('https://github.com/repo.git'), undefined); +}); + +test('returns undefined for URL with missing repo', t => { + t.is(parseGitUrl('https://github.com/owner/.git'), undefined); +}); + +test('returns undefined for shorthand notation (not handled by this function)', t => { + t.is(parseGitUrl('github:owner/repo'), undefined); + t.is(parseGitUrl('owner/repo'), undefined); +}); + +test('returns undefined for git:// protocol (not supported)', t => { + t.is(parseGitUrl('git://github.com/owner/repo.git'), undefined); +}); + +test('returns undefined for ftp:// protocol', t => { + t.is(parseGitUrl('ftp://github.com/owner/repo.git'), undefined); +}); + +test('returns undefined for URL with path beyond repo', t => { + t.is(parseGitUrl('https://github.com/owner/repo/extra/path.git'), undefined); +}); + +// ReDoS protection tests +test('handles very long hostname without catastrophic backtracking', t => { + const longHost = 'a'.repeat(10_000); + const url = `https://${longHost}.com/owner/repo.git`; + + // This should complete quickly (< 100ms) if ReDoS protection works + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://${longHost}.com/owner/repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles very long owner name without catastrophic backtracking', t => { + const longOwner = 'a'.repeat(10_000); + const url = `https://github.com/${longOwner}/repo.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://github.com/${longOwner}/repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles very long repo name without catastrophic backtracking', t => { + const longRepo = 'a'.repeat(10_000); + const url = `https://github.com/owner/${longRepo}.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://github.com/owner/${longRepo}`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with many slashes without hanging', t => { + const url = 'https://' + '/'.repeat(10_000) + 'github.com/owner/repo.git'; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should fail gracefully and quickly + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with many dots without hanging', t => { + const manyDots = '.'.repeat(10_000); + const url = `https://github.com/owner/${manyDots}repo.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // This is technically valid (dots followed by alphanumeric), just unusual + // The important part is that it completes quickly without hanging + t.is(result, `https://github.com/owner/${manyDots}repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('rejects repo name that is only dots', t => { + const onlyDots = '.'.repeat(100); + const url = `https://github.com/owner/${onlyDots}.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should reject because repo has no alphanumeric characters + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with alternating patterns without hanging', t => { + const url = 'https://github.com/' + 'a/'.repeat(5000) + 'repo.git'; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should fail gracefully and quickly + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles extremely long malformed URL without hanging', t => { + const url = 'x'.repeat(100_000); + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +// Real-world examples +test('parses real GitHub URL', t => { + t.is(parseGitUrl('https://github.com/sindresorhus/np.git'), 'https://github.com/sindresorhus/np'); +}); + +test('parses real GitLab URL', t => { + t.is(parseGitUrl('https://gitlab.com/gitlab-org/gitlab.git'), 'https://gitlab.com/gitlab-org/gitlab'); +}); + +test('parses real Bitbucket URL', t => { + t.is( + parseGitUrl('https://bitbucket.org/atlassian/python-bitbucket.git'), + 'https://bitbucket.org/atlassian/python-bitbucket', + ); +}); + +test('parses self-hosted GitLab instance', t => { + t.is( + parseGitUrl('git@gitlab.company.com:frontend/webapp.git'), + 'https://gitlab.company.com/frontend/webapp', + ); +}); + +// Normalization tests +test('normalizes HTTP to HTTPS', t => { + const result = parseGitUrl('http://github.com/owner/repo.git'); + t.true(result.startsWith('https://'), 'Should convert HTTP to HTTPS'); +}); + +test('removes .git suffix consistently', t => { + const withGit = parseGitUrl('https://github.com/owner/repo.git'); + const withoutGit = parseGitUrl('https://github.com/owner/repo'); + t.is(withGit, withoutGit, 'Should normalize .git suffix'); +}); + +// Case sensitivity tests +test('preserves case in hostnames', t => { + t.is( + parseGitUrl('https://GitHub.COM/owner/repo.git'), + 'https://GitHub.COM/owner/repo', + ); +}); + +test('preserves case in owner and repo names', t => { + t.is( + parseGitUrl('https://github.com/MyOrg/MyRepo.git'), + 'https://github.com/MyOrg/MyRepo', + ); +}); + +// Multiple protocol variations +test('handles git+https with custom host', t => { + t.is( + parseGitUrl('git+https://git.example.com/team/project.git'), + 'https://git.example.com/team/project', + ); +}); + +test('handles git+https without .git suffix on custom host', t => { + t.is( + parseGitUrl('git+https://git.example.com/team/project'), + 'https://git.example.com/team/project', + ); +}); + +test('handles ssh:// with custom host', t => { + t.is( + parseGitUrl('ssh://git@git.example.com/team/project.git'), + 'https://git.example.com/team/project', + ); +}); + +test('handles ssh:// without .git suffix on custom host', t => { + t.is( + parseGitUrl('ssh://git@git.example.com/team/project'), + 'https://git.example.com/team/project', + ); +}); + +// Whitespace handling +test('does not trim whitespace (garbage in, garbage out)', t => { + t.is(parseGitUrl(' https://github.com/owner/repo.git '), undefined); +}); + +test('does not handle URLs with internal whitespace', t => { + t.is(parseGitUrl('https://github.com/owner /repo.git'), undefined); +}); + +// Edge case: Double .git suffix +test('handles double .git suffix correctly', t => { + // With greedy matching, .git.git is interpreted as repo named "repo.git" + // This is the safer assumption (could be an actual repo named repo.git) + t.is(parseGitUrl('https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo.git'); +}); + +test('handles double .git suffix in SSH format', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git.git'), 'https://github.com/owner/repo.git'); +}); + +test('handles double .git suffix in git+https format', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo.git'); +}); + +// Edge case: Repo name contains .git +test('handles repo name containing .git (e.g., my.git.git)', t => { + // The repo is actually named "my.git", the URL is my.git.git + t.is(parseGitUrl('https://github.com/owner/my.git.git'), 'https://github.com/owner/my.git'); +}); + +test('handles repo name that is exactly .git', t => { + // Repo named ".git" (unusual but technically valid - has alphanumeric chars) + t.is(parseGitUrl('https://github.com/owner/.git.git'), 'https://github.com/owner/.git'); +}); + +test('handles repo with multiple .git patterns in name', t => { + t.is(parseGitUrl('https://github.com/owner/my.git.project.git'), 'https://github.com/owner/my.git.project'); +}); + +// Edge case: Scoped packages (@ symbol) +test('handles scoped package notation with @ symbol', t => { + // @ is allowed in owner names (though unusual for git URLs) + t.is(parseGitUrl('https://github.com/@scope/package.git'), 'https://github.com/@scope/package'); +}); + +test('handles SSH URL with @ in path', t => { + // @ is allowed in paths + t.is(parseGitUrl('git@github.com:@scope/package.git'), 'https://github.com/@scope/package'); +}); + +// Edge case: Single character names +test('handles single character owner', t => { + t.is(parseGitUrl('https://github.com/a/repo.git'), 'https://github.com/a/repo'); +}); + +test('handles single character repo', t => { + t.is(parseGitUrl('https://github.com/owner/r.git'), 'https://github.com/owner/r'); +}); + +test('handles single character for both owner and repo', t => { + t.is(parseGitUrl('https://github.com/a/b.git'), 'https://github.com/a/b'); +}); + +// Edge case: IP addresses as hosts +test('handles IPv4 address as host', t => { + t.is(parseGitUrl('https://192.168.1.1/owner/repo.git'), 'https://192.168.1.1/owner/repo'); +}); + +test('handles IPv4 address with port', t => { + t.is(parseGitUrl('https://192.168.1.1:8080/owner/repo.git'), 'https://192.168.1.1:8080/owner/repo'); +}); + +test('handles localhost as host', t => { + t.is(parseGitUrl('https://localhost/owner/repo.git'), 'https://localhost/owner/repo'); +}); + +test('handles localhost with port', t => { + t.is(parseGitUrl('http://localhost:3000/owner/repo.git'), 'https://localhost:3000/owner/repo'); +}); + +// Edge case: Multiple subdomains +test('handles multiple subdomains in host', t => { + t.is( + parseGitUrl('https://git.prod.company.example.com/owner/repo.git'), + 'https://git.prod.company.example.com/owner/repo', + ); +}); + +test('handles deeply nested subdomains', t => { + t.is( + parseGitUrl('https://a.b.c.d.e.f.example.com/owner/repo.git'), + 'https://a.b.c.d.e.f.example.com/owner/repo', + ); +}); + +// Edge case: Protocol variations +test('handles uppercase HTTP protocol', t => { + t.is(parseGitUrl('HTTP://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('handles uppercase HTTPS protocol', t => { + t.is(parseGitUrl('HTTPS://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('handles mixed case protocol', t => { + t.is(parseGitUrl('HtTpS://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +// Edge case: Malformed protocols +test('rejects malformed protocol (htp)', t => { + t.is(parseGitUrl('htp://github.com/owner/repo.git'), undefined); +}); + +test('rejects malformed protocol (htps)', t => { + t.is(parseGitUrl('htps://github.com/owner/repo.git'), undefined); +}); + +test('rejects protocol with extra characters', t => { + t.is(parseGitUrl('httpss://github.com/owner/repo.git'), undefined); +}); + +// Edge case: Numeric owner and repo +test('handles fully numeric owner', t => { + t.is(parseGitUrl('https://github.com/123456/repo.git'), 'https://github.com/123456/repo'); +}); + +test('handles fully numeric repo', t => { + t.is(parseGitUrl('https://github.com/owner/789012.git'), 'https://github.com/owner/789012'); +}); + +test('handles both owner and repo as numbers', t => { + t.is(parseGitUrl('https://github.com/123/456.git'), 'https://github.com/123/456'); +}); + +// Edge case: Special separators +test('rejects SSH URL with slash instead of colon', t => { + t.is(parseGitUrl('git@github.com/owner/repo.git'), undefined); +}); + +test('rejects git@ URL without colon separator', t => { + t.is(parseGitUrl('git@github.comowner/repo.git'), undefined); +}); + +// Edge case: Missing components +test('rejects URL with empty owner (double slash)', t => { + t.is(parseGitUrl('https://github.com//repo.git'), undefined); +}); + +test('rejects URL with empty repo', t => { + t.is(parseGitUrl('https://github.com/owner//.git'), undefined); +}); + +test('rejects SSH URL with empty owner', t => { + t.is(parseGitUrl('git@github.com:/repo.git'), undefined); +}); + +// Edge case: Trailing characters +test('rejects URL with trailing slash after .git', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git/'), undefined); +}); + +test('rejects URL with trailing text after .git', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git/extra'), undefined); +}); + +// Edge case: Special characters in names +test('handles all special chars together in name', t => { + t.is( + parseGitUrl('https://github.com/my-org_123.test/my-repo_456.test.git'), + 'https://github.com/my-org_123.test/my-repo_456.test', + ); +}); + +test('rejects owner with only special characters (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/---/repo.git'), undefined); +}); + +test('rejects repo with only special characters (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/owner/---.git'), undefined); +}); + +test('rejects owner with only dots (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/.../repo.git'), undefined); +}); + +// Edge case: URL-like patterns in repo names +test('handles repo name with dots that looks URL-like', t => { + t.is( + parseGitUrl('https://github.com/owner/example.com.git'), + 'https://github.com/owner/example.com', + ); +}); + +test('handles repo name with colons', t => { + // Colons are valid in URLs, but [^\s/] allows them + t.is( + parseGitUrl('https://github.com/owner/repo:v1.0.git'), + 'https://github.com/owner/repo:v1.0', + ); +}); + +// Edge case: git+ssh protocol (not supported) +test('rejects git+ssh protocol (not supported)', t => { + t.is(parseGitUrl('git+ssh://git@github.com/owner/repo.git'), undefined); +}); + +// Edge case: ssh with different username (not supported) +test('rejects ssh with non-git username', t => { + t.is(parseGitUrl('ssh://user@github.com/owner/repo.git'), undefined); +}); + +test('rejects ssh with numeric username', t => { + t.is(parseGitUrl('ssh://123@github.com/owner/repo.git'), undefined); +}); + +// Edge case: Query parameters and fragments (should reject) +test('strips query parameters', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main'), 'https://github.com/owner/repo'); +}); + +test('strips fragment', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git#readme'), 'https://github.com/owner/repo'); +}); + +test('strips query and fragment', t => { + t.is( + parseGitUrl('https://github.com/owner/repo.git?ref=main#readme'), + 'https://github.com/owner/repo', + ); +}); + +test('returns undefined when URL is only query', t => { + t.is(parseGitUrl('?ref=main'), undefined); +}); + +test('returns undefined when URL is only fragment', t => { + t.is(parseGitUrl('#main'), undefined); +}); + +// Edge case: Unusual but valid repo names +test('handles repo name starting with dot', t => { + t.is(parseGitUrl('https://github.com/owner/.dotfile.git'), 'https://github.com/owner/.dotfile'); +}); + +test('handles repo name with consecutive dots', t => { + t.is(parseGitUrl('https://github.com/owner/my..repo.git'), 'https://github.com/owner/my..repo'); +}); + +test('handles repo name ending with dash', t => { + t.is(parseGitUrl('https://github.com/owner/repo-.git'), 'https://github.com/owner/repo-'); +}); + +// Edge case: Case sensitivity preservation +test('preserves exact case in all components', t => { + t.is( + parseGitUrl('https://GitHub.COM/MyOrg/MyRepo.git'), + 'https://GitHub.COM/MyOrg/MyRepo', + ); +}); + +test('preserves case in SSH format', t => { + t.is( + parseGitUrl('git@GitHub.COM:MyOrg/MyRepo.git'), + 'https://GitHub.COM/MyOrg/MyRepo', + ); +}); diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js new file mode 100644 index 00000000..a508ef30 --- /dev/null +++ b/test/util/read-pkg.js @@ -0,0 +1,49 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import {temporaryDirectory} from 'tempy'; +import {readPackage, npPackage, npRootDirectory} from '../../source/util.js'; + +const rootDirectory = fileURLToPath(new URL('../..', import.meta.url)).slice(0, -1); + +test('without packagePath returns np package.json', async t => { + const {package_, rootDirectory: packageDirectory} = await readPackage(); + + t.is(package_.name, 'np'); + t.is(packageDirectory, rootDirectory); +}); + +test('with packagePath', async t => { + const fixtureDirectory = path.resolve(rootDirectory, 'test/fixtures/files/one-file'); + const {package_, rootDirectory: packageDirectory} = await readPackage(fixtureDirectory); + + t.is(package_.name, 'foo'); + t.is(packageDirectory, fixtureDirectory); +}); + +test('no package.json', async t => { + await t.throwsAsync( + readPackage(temporaryDirectory()), + {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, + ); +}); + +test('npPackage', t => { + t.is(npPackage.name, 'np'); +}); + +test('npRootDirectory', t => { + t.is(npRootDirectory, rootDirectory); +}); + +test('npRootDirectory is correct when process.cwd is different', async t => { + const cwd = temporaryDirectory(); + + /** @type {import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'node:process': {cwd}, + }); + + t.is(util.npRootDirectory, rootDirectory); +}); diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js new file mode 100644 index 00000000..58e84e40 --- /dev/null +++ b/test/util/validate-engine-version-satisfies.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import {validateEngineVersionSatisfies, npPackage} from '../../source/util.js'; + +const testEngineRanges = test.macro((t, engine, {above, below}) => { + const range = npPackage.engines[engine]; + + // Above minimum + t.notThrows(() => validateEngineVersionSatisfies(engine, above)); + + t.throws( + () => validateEngineVersionSatisfies(engine, below), // Below minimum + {message: `\`np\` requires ${engine} ${range}`}, + ); +}); + +test('node', testEngineRanges, 'node', {above: '99.7.0', below: '16.5.0'}); + +test('npm', testEngineRanges, 'npm', {above: '99.20.0', below: '7.18.0'}); + +test('git', testEngineRanges, 'git', {above: '99.12.0', below: '2.10.0'}); + +test('yarn', testEngineRanges, 'yarn', {above: '99.8.0', below: '1.6.0'}); + diff --git a/test/version.js b/test/version.js index 48cf4189..afeecc8d 100644 --- a/test/version.js +++ b/test/version.js @@ -1,120 +1,243 @@ import test from 'ava'; -import version from '../source/version'; +import sinon from 'sinon'; +import {template as chalk} from 'chalk-template'; +import semver from 'semver'; +import Version from '../source/version.js'; + +const INCREMENT_LIST = 'patch, minor, major, prepatch, preminor, premajor, prerelease'; +const INCREMENT_LIST_OR = 'patch, minor, major, prepatch, preminor, premajor, or prerelease'; + +/** @param {string} input - Place `{ }` around the version parts to be highlighted. */ +const makeNewFormattedVersion = input => { + input = input.replaceAll(/{([^}]*)}/g, '{cyan $1}'); // https://regex101.com/r/rZUIp4/1 + return chalk(`{dim ${input}}`); +}; + +test('new Version - valid', t => { + t.is(new Version('1.0.0').toString(), '1.0.0'); +}); + +test('new Version - invalid', t => { + t.throws( + () => new Version('major'), + {message: 'Version major should be a valid SemVer version.'}, + ); +}); + +test('new Version - valid w/ valid increment', t => { + t.is(new Version('1.0.0', 'major').toString(), '2.0.0'); +}); + +test('new Version - invalid w/ valid increment', t => { + t.throws( + () => new Version('major', 'major'), + {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}.`}, + ); +}); + +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.'}, + ); +}); + +// Input as SemVer increment is covered in constructor tests +test('setFrom - valid input as version', t => { + t.is(new Version('1.0.0').setFrom('2.0.0').toString(), '2.0.0'); +}); + +test('setFrom - invalid input as version', t => { + t.throws( + () => new Version('1.0.0').setFrom('200'), + {message: `New version 200 should either be one of ${INCREMENT_LIST}, or a valid SemVer version.`}, + ); +}); + +test('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.'}, + ); +}); + +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'); + + 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'); -test('version.SEMVER_INCREMENTS', t => { - t.deepEqual(version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); + 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('version.PRERELEASE_VERSIONS', t => { - t.deepEqual(version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); +test('format - patch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}'); + + t.is(new Version('0.0.0').setFrom('patch').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1').format(), newVersion); }); -test('version.isValidInput', t => { - t.false(version.isValidInput(null)); - t.false(version.isValidInput('foo')); - t.false(version.isValidInput('1.0.0.0')); +test('format - premajor', t => { + const newVersion = makeNewFormattedVersion('{1}.0.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')); + 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('version.isPrerelease', t => { - t.false(version('1.0.0').isPrerelease()); - t.false(version('1.1.0').isPrerelease()); - t.false(version('1.0.1').isPrerelease()); +test('format - preminor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.0-{0}'); - t.true(version('1.0.0-beta').isPrerelease()); - t.true(version('2.0.0-rc.2').isPrerelease()); + t.is(new Version('0.0.0').setFrom('preminor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0-0').format(), newVersion); }); -test('version.isPrereleaseOrIncrement', t => { - t.false(version.isPrereleaseOrIncrement('patch')); - t.false(version.isPrereleaseOrIncrement('minor')); - t.false(version.isPrereleaseOrIncrement('major')); +test('format - prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.true(version.isPrereleaseOrIncrement('prepatch')); - t.true(version.isPrereleaseOrIncrement('preminor')); - t.true(version.isPrereleaseOrIncrement('premajor')); - t.true(version.isPrereleaseOrIncrement('prerelease')); + 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('version.getNewVersionFrom', t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease or a valid semver version.'; +test('format - prerelease', t => { + const newVersion = makeNewFormattedVersion('0.0.0-{1}'); - t.throws(() => version('1.0.0').getNewVersionFrom('patchxxx'), message); - t.throws(() => version('1.0.0').getNewVersionFrom('1.0.0.0'), message); + 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); +}); - t.is(version('1.0.0').getNewVersionFrom('patch'), '1.0.1'); - t.is(version('1.0.0').getNewVersionFrom('minor'), '1.1.0'); - t.is(version('1.0.0').getNewVersionFrom('major'), '2.0.0'); +test('format - prerelease as prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.is(version('1.0.0-beta').getNewVersionFrom('major'), '1.0.0'); - t.is(version('1.0.0').getNewVersionFrom('prepatch'), '1.0.1-0'); - t.is(version('1.0.1-0').getNewVersionFrom('prepatch'), '1.0.2-0'); + t.is(new Version('0.0.0').setFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-0').format(), newVersion); +}); + +test('format - prerelease with multiple numbers', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-0.0').format(), newVersion); +}); - t.is(version('1.0.0-0').getNewVersionFrom('prerelease'), '1.0.0-1'); - t.is(version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); +test('format - prerelease with text', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{alpha.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); }); -test('version.validate', t => { - const message = 'Version should be a valid semver version.'; +test('format - prerelease diffs', t => { + const newVersion = makeNewFormattedVersion('0.0.0-1.{2}'); - 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-1.1').setFrom('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); - 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-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); }); -test('version.isGreaterThanOrEqualTo', t => { - t.false(version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); - t.false(version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); +test('format - custom colors', t => { + t.is( + new Version('1.2.3').format({color: 'green'}), + chalk('{green 1.2.3}'), + ); + + t.is( + new Version('1.2.3', 'minor').format({diffColor: 'red'}), + chalk('{dim 1.{red 3}.0}'), + ); + + t.is( + new Version('1.2.3', 'patch').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}}'), + ); + + t.is( + new Version('1.2.3', 'prerelease').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}-{yellow 0}}'), + ); +}); + +test('format - previousVersion as SemVer instance', t => { + const previousVersion = semver.parse('0.0.0'); + const newVersion = makeNewFormattedVersion('{1}.0.0'); + + const spy = sinon.spy(semver, 'parse'); - t.false(version('1.0.0').isGreaterThanOrEqualTo('1.0.0-0')); - t.false(version('1.0.0').isGreaterThanOrEqualTo('1.0.0-beta')); + t.is(new Version('1.0.0').format({previousVersion}), newVersion); + t.true(spy.calledOnce, 'semver.parse was called for previousVersion!'); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.0.0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.0.1')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.1.0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0')); + 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!'); +}); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0-0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); +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('version.isLowerThanOrEqualTo', t => { - t.true(version('1.0.0').isLowerThanOrEqualTo('0.0.1')); - t.true(version('1.0.0').isLowerThanOrEqualTo('0.1.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.true(version('1.0.0').isLowerThanOrEqualTo('1.0.0-0')); - t.true(version('1.0.0').isLowerThanOrEqualTo('1.0.0-beta')); - t.true(version('1.0.0').isLowerThanOrEqualTo('1.0.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.false(version('1.0.0').isLowerThanOrEqualTo('1.0.1')); - t.false(version('1.0.0').isLowerThanOrEqualTo('1.1.0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0')); + 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 => { + 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.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0-0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); + t.true(new Version('1.0.0-alpha.1').isPrerelease()); + t.true(new Version('1.0.0-beta').isPrerelease()); + t.true(new Version('2.0.0-rc.2').isPrerelease()); }); -test('version.satisfies', t => { - t.true(version('2.15.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('2.99.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('3.10.1').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('6.7.0-next.0').satisfies('<6.8.0')); - t.false(version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.false(version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); +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'); +}); + +test('setFrom with explicit version and format - for UI display', t => { + // This pattern is used in ui.js to display version info when user provides explicit version + const currentVersion = '1.0.0'; + const explicitVersion = '2.5.0'; + const formattedVersion = new Version(currentVersion).setFrom(explicitVersion).format(); + + t.is(formattedVersion, makeNewFormattedVersion('{2}.5.0')); });