From d597d656bb4ca5e8a0645ade2c230ec83ff98e26 Mon Sep 17 00:00:00 2001 From: Gar Date: Wed, 14 Dec 2022 10:52:05 -0800 Subject: [PATCH 1/2] feat: add --expect-entries to `npm query` This will allow users to tell npm whether or not to exit with an exit code depending on if the command had any resulting entries or not. --- docs/lib/content/commands/npm-query.md | 31 +++++-- lib/base-command.js | 19 +++++ lib/commands/query.js | 3 + .../test/lib/commands/config.js.test.cjs | 4 + tap-snapshots/test/lib/docs.js.test.cjs | 26 ++++++ test/lib/commands/query.js | 83 +++++++++++++++++++ .../config/lib/definitions/definitions.js | 20 +++++ .../test/type-description.js.test.cjs | 8 ++ 8 files changed, 185 insertions(+), 9 deletions(-) diff --git a/docs/lib/content/commands/npm-query.md b/docs/lib/content/commands/npm-query.md index 9907d4aa2c79c..490eccffcc4b3 100644 --- a/docs/lib/content/commands/npm-query.md +++ b/docs/lib/content/commands/npm-query.md @@ -133,19 +133,32 @@ npm query ":type(git)" | jq 'map(.name)' | xargs -I {} npm why {} }, ... ``` -### Package lock only mode -If package-lock-only is enabled, only the information in the package -lock (or shrinkwrap) is loaded. This means that information from the -package.json files of your dependencies will not be included in the -result set (e.g. description, homepage, engines). +### Expecting a certain number of results + +One common use of `npm query` is to make sure there is only one version of +a certain dependency in your tree. This is especially common for +ecosystems like that rely on `typescript` where having state split +across two different but identically-named packages causes bugs. You +can use the `--expect-results` or `--expect-result-count` in your setup +to ensure that npm will exit with an exit code if your tree doesn't look +like you want it to. + + +```sh +$ npm query '#react' --expect-result-count=1 +``` + +Perhaps you want to quickly check if there are any production +dependencies that could be updated: + +```sh +$ npm query ':root>:outdated(in-range).prod' --no-expect-results +``` ### Package lock only mode -If package-lock-only is enabled, only the information in the package -lock (or shrinkwrap) is loaded. This means that information from the -package.json files of your dependencies will not be included in the -result set (e.g. description, homepage, engines). +If package-lock-only is enabled, only the information in the package lock (or shrinkwrap) is loaded. This means that information from the package.json files of your dependencies will not be included in the result set (e.g. description, homepage, engines). ### Configuration diff --git a/lib/base-command.js b/lib/base-command.js index e763820cb052b..e4a7bbbec724a 100644 --- a/lib/base-command.js +++ b/lib/base-command.js @@ -5,6 +5,7 @@ const { relative } = require('path') const { definitions } = require('@npmcli/config/lib/definitions') const getWorkspaces = require('./workspaces/get-workspaces.js') const { aliases: cmdAliases } = require('./utils/cmd-list') +const log = require('./utils/log-shim.js') class BaseCommand { static workspaces = false @@ -142,6 +143,24 @@ class BaseCommand { return this.exec(args) } + // Compare the number of entries with what was expected + checkExpected (entries) { + if (!this.npm.config.isDefault('expect-results')) { + const expected = this.npm.config.get('expect-results') + if (!!entries !== !!expected) { + log.warn(this.name, `Expected ${expected ? '' : 'no '}results, got ${entries}`) + process.exitCode = 1 + } + } else if (!this.npm.config.isDefault('expect-result-count')) { + const expected = this.npm.config.get('expect-result-count') + if (expected !== entries) { + /* eslint-disable-next-line max-len */ + log.warn(this.name, `Expected ${expected} result${expected === 1 ? '' : 's'}, got ${entries}`) + process.exitCode = 1 + } + } + } + async setWorkspaces () { const includeWorkspaceRoot = this.isArboristCmd ? false diff --git a/lib/commands/query.js b/lib/commands/query.js index 68aa1fa2c06a5..17a55a446b086 100644 --- a/lib/commands/query.js +++ b/lib/commands/query.js @@ -50,6 +50,7 @@ class Query extends BaseCommand { 'workspaces', 'include-workspace-root', 'package-lock-only', + 'expect-results', ] get parsedResponse () { @@ -81,6 +82,7 @@ class Query extends BaseCommand { const items = await tree.querySelectorAll(args[0], this.npm.flatOptions) this.buildResponse(items) + this.checkExpected(this.#response.length) this.npm.output(this.parsedResponse) } @@ -104,6 +106,7 @@ class Query extends BaseCommand { } this.buildResponse(items) } + this.checkExpected(this.#response.length) this.npm.output(this.parsedResponse) } diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 6c4a27ca5916b..8c13f825dc376 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -50,6 +50,8 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "dry-run": false, "editor": "{EDITOR}", "engine-strict": false, + "expect-results": null, + "expect-result-count": null, "fetch-retries": 2, "fetch-retry-factor": 10, "fetch-retry-maxtimeout": 60000, @@ -207,6 +209,8 @@ diff-unified = 3 dry-run = false editor = "{EDITOR}" engine-strict = false +expect-result-count = null +expect-results = null fetch-retries = 2 fetch-retry-factor = 10 fetch-retry-maxtimeout = 60000 diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 5ea1c9e4383b2..cf5b74eeda843 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -537,6 +537,25 @@ This can be overridden by setting the \`--force\` flag. +#### \`expect-result-count\` + +* Default: null +* Type: null or Number + +Tells to expect a specific number of results from the command. + +This config can not be used with: \`expect-results\` + +#### \`expect-results\` + +* Default: null +* Type: null or Boolean + +Tells npm whether or not to expect results from the command. Can be either +true (expect some results) or false (expect no results). + +This config can not be used with: \`expect-result-count\` + #### \`fetch-retries\` * Default: 2 @@ -2074,6 +2093,8 @@ Array [ "dry-run", "editor", "engine-strict", + "expect-results", + "expect-result-count", "fetch-retries", "fetch-retry-factor", "fetch-retry-maxtimeout", @@ -2325,6 +2346,8 @@ Array [ exports[`test/lib/docs.js TAP config > keys that are not flattened 1`] = ` Array [ + "expect-results", + "expect-result-count", "init-author-email", "init-author-name", "init-author-url", @@ -3869,6 +3892,7 @@ Options: [-g|--global] [-w|--workspace [-w|--workspace ...]] [-ws|--workspaces] [--include-workspace-root] [--package-lock-only] +[--expect-results|--expect-result-count ] Run "npm help query" for more info @@ -3881,6 +3905,8 @@ npm query #### \`workspaces\` #### \`include-workspace-root\` #### \`package-lock-only\` +#### \`expect-results\` +#### \`expect-result-count\` ` exports[`test/lib/docs.js TAP usage rebuild > must match snapshot 1`] = ` diff --git a/test/lib/commands/query.js b/test/lib/commands/query.js index adf617316772e..ebe89ed0da278 100644 --- a/test/lib/commands/query.js +++ b/test/lib/commands/query.js @@ -61,6 +61,7 @@ t.test('recursive tree', async t => { await npm.exec('query', ['*']) t.matchSnapshot(joinedOutput(), 'should return everything in the tree, accounting for recursion') }) + t.test('workspace query', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { config: { @@ -237,3 +238,85 @@ t.test('package-lock-only', t => { }) t.end() }) + +t.test('expect entries', t => { + const { exitCode } = process + t.afterEach(() => process.exitCode = exitCode) + const prefixDir = { + node_modules: { + a: { name: 'a', version: '1.0.0' }, + }, + 'package.json': JSON.stringify({ + name: 'project', + dependencies: { a: '^1.0.0' }, + }), + } + t.test('false, has entries', async t => { + const { logs, npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-results', false) + await npm.exec('query', ['#a']) + t.not(joinedOutput(), '[]', 'has entries') + t.same(logs.warn, [['query', 'Expected no results, got 1']]) + t.ok(process.exitCode, 'exits with code') + }) + t.test('false, no entries', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-results', false) + await npm.exec('query', ['#b']) + t.equal(joinedOutput(), '[]', 'does not have entries') + t.notOk(process.exitCode, 'exits without code') + }) + t.test('true, has entries', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-results', true) + await npm.exec('query', ['#a']) + t.not(joinedOutput(), '[]', 'has entries') + t.notOk(process.exitCode, 'exits without code') + }) + t.test('true, no entries', async t => { + const { logs, npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-results', true) + await npm.exec('query', ['#b']) + t.equal(joinedOutput(), '[]', 'does not have entries') + t.same(logs.warn, [['query', 'Expected results, got 0']]) + t.ok(process.exitCode, 'exits with code') + }) + t.test('count, matches', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-result-count', 1) + await npm.exec('query', ['#a']) + t.not(joinedOutput(), '[]', 'has entries') + t.notOk(process.exitCode, 'exits without code') + }) + t.test('count 1, does not match', async t => { + const { logs, npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-result-count', 1) + await npm.exec('query', ['#b']) + t.equal(joinedOutput(), '[]', 'does not have entries') + t.same(logs.warn, [['query', 'Expected 1 result, got 0']]) + t.ok(process.exitCode, 'exits with code') + }) + t.test('count 3, does not match', async t => { + const { logs, npm, joinedOutput } = await loadMockNpm(t, { + prefixDir, + }) + npm.config.set('expect-result-count', 3) + await npm.exec('query', ['#b']) + t.equal(joinedOutput(), '[]', 'does not have entries') + t.same(logs.warn, [['query', 'Expected 3 results, got 0']]) + t.ok(process.exitCode, 'exits with code') + }) + t.end() +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 6f8760fce1d3e..1f320dfc9d9b6 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -665,6 +665,26 @@ define('engine-strict', { flatten, }) +define('expect-results', { + default: null, + type: [null, Boolean], + exclusive: ['expect-result-count'], + description: ` + Tells npm whether or not to expect results from the command. + Can be either true (expect some results) or false (expect no results). + `, +}) + +define('expect-result-count', { + default: null, + type: [null, Number], + hint: '', + exclusive: ['expect-results'], + description: ` + Tells to expect a specific number of results from the command. + `, +}) + define('fetch-retries', { default: 2, type: Number, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 78fca4ef3eb94..a78afd3d4ba75 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -139,6 +139,14 @@ Object { "engine-strict": Array [ "boolean value (true or false)", ], + "expect-result-count": Array [ + null, + "numeric value", + ], + "expect-results": Array [ + null, + "boolean value (true or false)", + ], "fetch-retries": Array [ "numeric value", ], From 86e6fce9130f98af60d448fb0bdb8b94e93d231e Mon Sep 17 00:00:00 2001 From: Gar Date: Tue, 6 Feb 2024 09:55:18 -0800 Subject: [PATCH 2/2] docs: clarify in-range and out-of-range in dependency selectors --- docs/lib/content/using-npm/dependency-selectors.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/lib/content/using-npm/dependency-selectors.md b/docs/lib/content/using-npm/dependency-selectors.md index 87a722748c261..ffc69dcc7a863 100644 --- a/docs/lib/content/using-npm/dependency-selectors.md +++ b/docs/lib/content/using-npm/dependency-selectors.md @@ -84,8 +84,8 @@ Some examples: The `:outdated` pseudo selector retrieves data from the registry and returns information about which of your dependencies are outdated. The type parameter may be one of the following: - `any` (default) a version exists that is greater than the current one -- `in-range` a version exists that is greater than the current one, and satisfies at least one if its dependents -- `out-of-range` a version exists that is greater than the current one, does not satisfy at least one of its dependents +- `in-range` a version exists that is greater than the current one, and satisfies at least one if its parent's dependencies +- `out-of-range` a version exists that is greater than the current one, does not satisfy at least one of its parent's dependencies - `major` a version exists that is a semver major greater than the current one - `minor` a version exists that is a semver minor greater than the current one - `patch` a version exists that is a semver patch greater than the current one @@ -99,7 +99,7 @@ In addition to the filtering performed by the pseudo selector, some extra data i Some examples: - `:root > :outdated(major)` returns every direct dependency that has a new semver major release -- `.prod:outdated(in-range)` returns production dependencies that have a new release that satisfies at least one of its edges in +- `.prod:outdated(in-range)` returns production dependencies that have a new release that satisfies at least one of its parent's dependencies #### [Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors)