From 4f3ddbbe88df7c94d1e06e660928a962e973f332 Mon Sep 17 00:00:00 2001 From: Gar Date: Wed, 14 Dec 2022 10:52:05 -0800 Subject: [PATCH] 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", ],