From 4e52217cb25a697b0f6b0131bcb8c87e0dbcda53 Mon Sep 17 00:00:00 2001 From: nlf Date: Tue, 24 Aug 2021 13:53:09 -0700 Subject: [PATCH] fix(config): respect --global, --package-lock-only This corrects two things, `--global` implies `--location=global` and `--package-lock-only` implies `--package-lock` It also introduces a new sandbox runner for testing purposes. it's not the prettiest thing i've ever written, but it seems to do the job pretty nicely and doesn't require keeping track of wild shenanigans everywhere. Fixes #2747 Fixes #3572 PR-URL: https://github.com/npm/cli/pull/3684 Credit: @nlf Close: #3684 Reviewed-by: @wraithgar --- lib/config.js | 11 +- lib/utils/config/definitions.js | 30 +- tap-snapshots/test/lib/config.js.test.cjs | 446 ++++++++---- test/fixtures/sandbox.js | 361 ++++++++++ test/lib/config.js | 816 +++++++--------------- test/lib/utils/config/definitions.js | 31 +- 6 files changed, 993 insertions(+), 702 deletions(-) create mode 100644 test/fixtures/sandbox.js diff --git a/lib/config.js b/lib/config.js index a56dd92ffbde6..2df7bf513437c 100644 --- a/lib/config.js +++ b/lib/config.js @@ -121,7 +121,7 @@ class Config extends BaseCommand { break case 'list': case 'ls': - await (this.npm.config.get('json') ? this.listJson() : this.list()) + await (this.npm.flatOptions.json ? this.listJson() : this.list()) break case 'edit': await this.edit() @@ -138,7 +138,7 @@ class Config extends BaseCommand { if (!args.length) throw this.usageError() - const where = this.npm.config.get('location') + const where = this.npm.flatOptions.location for (const [key, val] of Object.entries(keyValues(args))) { this.npm.log.info('config', 'set %j %j', key, val) this.npm.config.set(key, val || '', where) @@ -168,15 +168,15 @@ class Config extends BaseCommand { if (!keys.length) throw this.usageError() - const where = this.npm.config.get('location') + const where = this.npm.flatOptions.location for (const key of keys) this.npm.config.delete(key, where) await this.npm.config.save(where) } async edit () { - const e = this.npm.config.get('editor') - const where = this.npm.config.get('location') + const e = this.npm.flatOptions.editor + const where = this.npm.flatOptions.location const file = this.npm.config.data.get(where).source // save first, just to make sure it's synced up @@ -232,6 +232,7 @@ ${defData} async list () { const msg = [] + // long does not have a flattener const long = this.npm.config.get('long') for (const [where, { data, source }] of this.npm.config.data.entries()) { if (where === 'default' && !long) diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js index c71781627872a..092e0fc435cb4 100644 --- a/lib/utils/config/definitions.js +++ b/lib/utils/config/definitions.js @@ -804,7 +804,11 @@ define('global', { * bin files are linked to \`{prefix}/bin\` * man pages are linked to \`{prefix}/share/man\` `, - flatten, + flatten: (key, obj, flatOptions) => { + flatten(key, obj, flatOptions) + if (flatOptions.global) + flatOptions.location = 'global' + }, }) define('global-style', { @@ -1131,14 +1135,10 @@ define('location', { description: ` When passed to \`npm config\` this refers to which config file to use. `, - // NOTE: the flattener here deliberately does not alter the value of global - // for now, this is to avoid inadvertently causing any breakage. the value of - // global, however, does modify this flag. - flatten (key, obj, flatOptions) { - // if global is set, we override ourselves - if (obj.global) - obj.location = 'global' - flatOptions.location = obj.location + flatten: (key, obj, flatOptions) => { + flatten(key, obj, flatOptions) + if (flatOptions.global) + flatOptions.location = 'global' }, }) @@ -1359,7 +1359,11 @@ define('package-lock', { modules will also be disabled. To remove extraneous modules with package-locks disabled use \`npm prune\`. `, - flatten, + flatten: (key, obj, flatOptions) => { + flatten(key, obj, flatOptions) + if (flatOptions.packageLockOnly) + flatOptions.packageLock = true + }, }) define('package-lock-only', { @@ -1375,7 +1379,11 @@ define('package-lock-only', { For \`list\` this means the output will be based on the tree described by the \`package-lock.json\`, rather than the contents of \`node_modules\`. `, - flatten, + flatten: (key, obj, flatOptions) => { + flatten(key, obj, flatOptions) + if (flatOptions.packageLockOnly) + flatOptions.packageLock = true + }, }) define('pack-destination', { diff --git a/tap-snapshots/test/lib/config.js.test.cjs b/tap-snapshots/test/lib/config.js.test.cjs index b5acbb0af94c5..817f3c173f19e 100644 --- a/tap-snapshots/test/lib/config.js.test.cjs +++ b/tap-snapshots/test/lib/config.js.test.cjs @@ -5,158 +5,334 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/lib/config.js TAP config edit --location=global > should write global config file 1`] = ` -;;;; -; npm globalconfig file: /etc/npmrc -; this is a simple ini-formatted file -; lines that start with semi-colons are comments -; run \`npm help 7 config\` for documentation of the various options -; -; Configs like \`@scope:registry\` map a scope to a given registry url. -; -; Configs like \`///:_authToken\` are auth that is restricted -; to the registry host specified. - -init.author.name=Foo - -;;;; -; all available options shown below with default values -;;;; - - -; init-author-name= -; init-version=1.0.0 -; init.author.name= -; init.version=1.0.0 - -` - -exports[`test/lib/config.js TAP config edit > should write config file 1`] = ` -;;;; -; npm userconfig file: ~/.npmrc -; this is a simple ini-formatted file -; lines that start with semi-colons are comments -; run \`npm help 7 config\` for documentation of the various options -; -; Configs like \`@scope:registry\` map a scope to a given registry url. -; -; Configs like \`///:_authToken\` are auth that is restricted -; to the registry host specified. - -//registry.npmjs.org/:_authToken=0000000 -init.author.name=Foo -sign-git-commit=true - -;;;; -; all available options shown below with default values -;;;; - - -; init-author-name= -; init-version=1.0.0 -; init.author.name= -; init.version=1.0.0 - -` - -exports[`test/lib/config.js TAP config edit > should write config file 2`] = ` -;;;; -; npm userconfig file: ~/.npmrc -; this is a simple ini-formatted file -; lines that start with semi-colons are comments -; run \`npm help 7 config\` for documentation of the various options -; -; Configs like \`@scope:registry\` map a scope to a given registry url. -; -; Configs like \`///:_authToken\` are auth that is restricted -; to the registry host specified. - - - -;;;; -; all available options shown below with default values -;;;; - - -; init-author-name= -; init-version=1.0.0 -; init.author.name= -; init.version=1.0.0 - +exports[`test/lib/config.js TAP config list --json > output matches snapshot 1`] = ` +{ + "prefix": "{LOCALPREFIX}", + "userconfig": "{HOME}/.npmrc", + "json": true, + "projectloaded": "yes", + "userloaded": "yes", + "globalloaded": "yes", + "access": null, + "all": false, + "allow-same-version": false, + "also": null, + "audit": true, + "audit-level": null, + "auth-type": "legacy", + "before": null, + "bin-links": true, + "browser": null, + "ca": null, + "cache": "{CACHE}", + "cache-max": null, + "cache-min": 0, + "cafile": null, + "call": "", + "cert": null, + "ci-name": null, + "cidr": null, + "color": true, + "commit-hooks": true, + "depth": null, + "description": true, + "dev": false, + "diff": [], + "diff-ignore-all-space": false, + "diff-name-only": false, + "diff-no-prefix": false, + "diff-dst-prefix": "b/", + "diff-src-prefix": "a/", + "diff-text": false, + "diff-unified": 3, + "dry-run": false, + "editor": "{EDITOR}", + "engine-strict": false, + "fetch-retries": 2, + "fetch-retry-factor": 10, + "fetch-retry-maxtimeout": 60000, + "fetch-retry-mintimeout": 10000, + "fetch-timeout": 300000, + "force": false, + "foreground-scripts": false, + "format-package-lock": true, + "fund": true, + "git": "git", + "git-tag-version": true, + "global": false, + "global-style": false, + "globalconfig": "{GLOBALPREFIX}/npmrc", + "heading": "npm", + "https-proxy": null, + "if-present": false, + "ignore-scripts": false, + "include": [], + "include-staged": false, + "init-author-email": "", + "init-author-name": "", + "init-author-url": "", + "init-license": "ISC", + "init-module": "{HOME}/.npm-init.js", + "init-version": "1.0.0", + "init.author.email": "", + "init.author.name": "", + "init.author.url": "", + "init.license": "ISC", + "init.module": "{HOME}/.npm-init.js", + "init.version": "1.0.0", + "key": null, + "legacy-bundling": false, + "legacy-peer-deps": false, + "link": false, + "local-address": null, + "location": "user", + "loglevel": "notice", + "logs-max": 10, + "long": false, + "maxsockets": 15, + "message": "%s", + "node-options": null, + "node-version": "{NODE-VERSION}", + "noproxy": [ + "" + ], + "npm-version": "{NPM-VERSION}", + "offline": false, + "omit": [], + "only": null, + "optional": null, + "otp": null, + "package": [], + "package-lock": true, + "package-lock-only": false, + "pack-destination": ".", + "parseable": false, + "prefer-offline": false, + "prefer-online": false, + "preid": "", + "production": null, + "progress": true, + "proxy": null, + "read-only": false, + "rebuild-bundle": true, + "registry": "https://registry.npmjs.org/", + "save": true, + "save-bundle": false, + "save-dev": false, + "save-exact": false, + "save-optional": false, + "save-peer": false, + "save-prefix": "^", + "save-prod": false, + "scope": "", + "script-shell": null, + "searchexclude": "", + "searchlimit": 20, + "searchopts": "", + "searchstaleness": 900, + "shell": "{SHELL}", + "shrinkwrap": true, + "sign-git-commit": false, + "sign-git-tag": false, + "sso-poll-frequency": 500, + "sso-type": "oauth", + "strict-peer-deps": false, + "strict-ssl": true, + "tag": "latest", + "tag-version-prefix": "v", + "timing": false, + "tmp": "{TMP}", + "umask": 0, + "unicode": false, + "update-notifier": true, + "usage": false, + "user-agent": "npm/{NPM-VERSION} node/{NODE-VERSION} {PLATFORM} {ARCH} workspaces/false", + "version": false, + "versions": false, + "viewer": "{VIEWER}", + "which": null, + "workspace": [], + "workspaces": false, + "yes": null, + "metrics-registry": "https://registry.npmjs.org/" +} ` -exports[`test/lib/config.js TAP config get no args > should list configs on config get no args 1`] = ` -; "cli" config from command line options - -cat = true -chai = true -dog = true -editor = "vi" -json = false -location = "user" -long = false - -; node bin location = /path/to/node -; cwd = {CWD} -; HOME = ~/ -; Run \`npm config ls -l\` to show all defaults. -` - -exports[`test/lib/config.js TAP config list --long > should list all configs 1`] = ` +exports[`test/lib/config.js TAP config list --long > output matches snapshot 1`] = ` ; "default" config from default values +_auth = (protected) +access = null +all = false +allow-same-version = false +also = null +audit = true +audit-level = null +auth-type = "legacy" +before = null +bin-links = true +browser = null +ca = null +cache = "{CACHE}" +cache-max = null +cache-min = 0 +cafile = null +call = "" +cert = null +ci-name = null +cidr = null +color = true +commit-hooks = true +depth = null +description = true +dev = false +diff = [] +diff-dst-prefix = "b/" +diff-ignore-all-space = false +diff-name-only = false +diff-no-prefix = false +diff-src-prefix = "a/" +diff-text = false +diff-unified = 3 +dry-run = false +editor = "{EDITOR}" +engine-strict = false +fetch-retries = 2 +fetch-retry-factor = 10 +fetch-retry-maxtimeout = 60000 +fetch-retry-mintimeout = 10000 +fetch-timeout = 300000 +force = false +foreground-scripts = false +format-package-lock = true +fund = true +git = "git" +git-tag-version = true +global = false +global-style = false +globalconfig = "{GLOBALPREFIX}/npmrc" +heading = "npm" +https-proxy = null +if-present = false +ignore-scripts = false +include = [] +include-staged = false +init-author-email = "" init-author-name = "" +init-author-url = "" +init-license = "ISC" +init-module = "{HOME}/.npm-init.js" init-version = "1.0.0" +init.author.email = "" init.author.name = "" +init.author.url = "" +init.license = "ISC" +init.module = "{HOME}/.npm-init.js" init.version = "1.0.0" - -; "cli" config from command line options - -cat = true -chai = true -dog = true -editor = "vi" json = false +key = null +legacy-bundling = false +legacy-peer-deps = false +link = false +local-address = null location = "user" -long = true -` +loglevel = "notice" +logs-max = 10 +; long = false ; overridden by cli +maxsockets = 15 +message = "%s" +metrics-registry = "https://registry.npmjs.org/" +node-options = null +node-version = "{NODE-VERSION}" +noproxy = [""] +npm-version = "{NPM-VERSION}" +offline = false +omit = [] +only = null +optional = null +otp = null +pack-destination = "." +package = [] +package-lock = true +package-lock-only = false +parseable = false +prefer-offline = false +prefer-online = false +; prefix = "{REALGLOBALREFIX}" ; overridden by cli +preid = "" +production = null +progress = true +proxy = null +read-only = false +rebuild-bundle = true +registry = "https://registry.npmjs.org/" +save = true +save-bundle = false +save-dev = false +save-exact = false +save-optional = false +save-peer = false +save-prefix = "^" +save-prod = false +scope = "" +script-shell = null +searchexclude = "" +searchlimit = 20 +searchopts = "" +searchstaleness = 900 +shell = "{SHELL}" +shrinkwrap = true +sign-git-commit = false +sign-git-tag = false +sso-poll-frequency = 500 +sso-type = "oauth" +strict-peer-deps = false +strict-ssl = true +tag = "latest" +tag-version-prefix = "v" +timing = false +tmp = "{TMP}" +umask = 0 +unicode = false +update-notifier = true +usage = false +user-agent = "npm/{NPM-VERSION} node/{NODE-VERSION} {PLATFORM} {ARCH} workspaces/false" +; userconfig = "{HOME}/.npmrc" ; overridden by cli +version = false +versions = false +viewer = "{VIEWER}" +which = null +workspace = [] +workspaces = false +yes = null + +; "global" config from {GLOBALPREFIX}/npmrc + +globalloaded = "yes" + +; "user" config from {HOME}/.npmrc + +userloaded = "yes" + +; "project" config from {LOCALPREFIX}/.npmrc + +projectloaded = "yes" -exports[`test/lib/config.js TAP config list > should list configs 1`] = ` ; "cli" config from command line options -cat = true -chai = true -dog = true -editor = "vi" -json = false -location = "user" -long = false - -; node bin location = /path/to/node -; cwd = {CWD} -; HOME = ~/ -; Run \`npm config ls -l\` to show all defaults. +long = true +prefix = "{LOCALPREFIX}" +userconfig = "{HOME}/.npmrc" ` -exports[`test/lib/config.js TAP config list overrides > should list overridden configs 1`] = ` +exports[`test/lib/config.js TAP config list > output matches snapshot 1`] = ` ; "cli" config from command line options -cat = true -chai = true -dog = true -editor = "vi" -init.author.name = "Bar" -json = false -location = "user" -long = false - -; "user" config from ~/.npmrc - -; //private-reg.npmjs.org/:_authThoken = (protected) ; overridden by cli -; init.author.name = "Foo" ; overridden by cli +prefix = "{LOCALPREFIX}" +userconfig = "{HOME}/.npmrc" -; node bin location = /path/to/node -; cwd = {CWD} -; HOME = ~/ +; node bin location = {EXECPATH} +; cwd = {NPMDIR} +; HOME = {HOME} ; Run \`npm config ls -l\` to show all defaults. ` diff --git a/test/fixtures/sandbox.js b/test/fixtures/sandbox.js new file mode 100644 index 0000000000000..45af373d2753a --- /dev/null +++ b/test/fixtures/sandbox.js @@ -0,0 +1,361 @@ +const { createHook, executionAsyncId } = require('async_hooks') +const { EventEmitter } = require('events') +const { homedir, tmpdir } = require('os') +const { dirname, join } = require('path') +const { promisify } = require('util') +const mkdirp = require('mkdirp-infer-owner') +const npmlog = require('npmlog') +const rimraf = promisify(require('rimraf')) +const t = require('tap') + +let active = null +const chain = new Map() +const sandboxes = new Map() + +// keep a reference to the real process +const _process = process + +const processHook = createHook({ + init: (asyncId, type, triggerAsyncId, resource) => { + // track parentage of asyncIds + chain.set(asyncId, triggerAsyncId) + }, + before: (asyncId) => { + // find the nearest parent id that has a sandbox + let parent = asyncId + while (chain.has(parent) && !sandboxes.has(parent)) { + parent = chain.get(parent) + } + + process = sandboxes.has(parent) + ? sandboxes.get(parent) + : _process + }, +}).enable() + +for (const level in npmlog.levels) { + npmlog[`_${level}`] = npmlog[level] + npmlog[level] = (...args) => { + process._logs = process._logs || {} + process._logs[level] = process._logs[level] || [] + process._logs[level].push(args) + const _level = npmlog.level + npmlog.level = 'silent' + npmlog[`_${level}`](...args) + npmlog.level = _level + } +} + +const _data = Symbol('sandbox.data') +const _dirs = Symbol('sandbox.dirs') +const _test = Symbol('sandbox.test') +const _mocks = Symbol('sandbox.mocks') +const _npm = Symbol('sandbox.npm') +const _parent = Symbol('sandbox.parent') +const _output = Symbol('sandbox.output') +const _proxy = Symbol('sandbox.proxy') +const _get = Symbol('sandbox.proxy.get') +const _set = Symbol('sandbox.proxy.set') + +// these config keys can be redacted widely +const redactedDefaults = [ + 'node-version', + 'npm-version', + 'tmp', +] + +// we can't just replace these values everywhere because they're known to be +// very short strings that could be present all over the place, so we only +// replace them if they're located within quotes for now +const vagueRedactedDefaults = [ + 'editor', + 'shell', +] + +const normalize = (str) => str + .replace(/\r\n/g, '\n') // normalize line endings (for ini) + .replace(/[A-z]:\\/g, '\\') // turn windows roots to posix ones + .replace(/\\+/g, '/') // replace \ with / + +class Sandbox extends EventEmitter { + constructor (test, options = {}) { + super() + + this[_test] = test + this[_mocks] = options.mocks || {} + this[_data] = new Map() + this[_output] = [] + const tempDir = `${test.testdirName}-sandbox` + this[_dirs] = { + temp: tempDir, + global: options.global || join(tempDir, 'global'), + home: options.home || join(tempDir, 'home'), + project: options.project || join(tempDir, 'project'), + } + + this[_proxy] = new Proxy(_process, { + get: this[_get].bind(this), + set: this[_set].bind(this), + }) + this[_proxy].env = {} + this[_proxy].argv = [] + + test.cleanSnapshot = this.cleanSnapshot.bind(this) + test.afterEach(() => this.reset()) + test.teardown(() => this.teardown()) + } + + get config () { + return this[_npm] && this[_npm].config + } + + get logs () { + return this[_proxy]._logs + } + + get global () { + return this[_dirs].global + } + + get home () { + return this[_dirs].home + } + + get project () { + return this[_dirs].project + } + + get process () { + return this[_proxy] + } + + get output () { + return this[_output].map((line) => line.join(' ')).join('\n') + } + + cleanSnapshot (snapshot) { + let clean = normalize(snapshot) + + if (this[_npm]) { + // replace default config values with placeholders + for (const name of redactedDefaults) { + let value = this[_npm].config.defaults[name] + clean = clean.split(value).join(`{${name.toUpperCase()}}`) + } + + // replace vague default config values that are present within quotes + // with placeholders + for (const name of vagueRedactedDefaults) { + const value = this[_npm].config.defaults[name] + clean = clean.split(`"${value}"`).join(`"{${name.toUpperCase()}}"`) + } + } + + const viewer = _process.platform === 'win32' + ? /"browser"([^:]+|$)/g + : /"man"([^:]+|$)/g + + // the global prefix is platform dependent + const realGlobalPrefix = _process.platform === 'win32' + ? dirname(_process.execPath) + : dirname(dirname(_process.execPath)) + + const cache = _process.platform === 'win32' + ? /\{HOME\}\/npm-cache(\r?\n|"|\/|$)/g + : /\{HOME\}\/\.npm(\n|"|\/|$)/g + + // and finally replace some paths we know could be present + clean = clean + .replace(viewer, '"{VIEWER}"$1') + .split(normalize(this[_proxy].execPath)).join('{EXECPATH}') + .split(normalize(_process.execPath)).join('{REALEXECPATH}') + .split(normalize(this.global)).join('{GLOBALPREFIX}') + .split(normalize(realGlobalPrefix)).join('{REALGLOBALREFIX}') + .split(normalize(this.project)).join('{LOCALPREFIX}') + .split(normalize(this.home)).join('{HOME}') + .replace(cache, '{CACHE}$1') + .split(normalize(dirname(dirname(__dirname)))).join('{NPMDIR}') + .split(normalize(tmpdir())).join('{TMP}') + .split(normalize(homedir())).join('{REALHOME}') + .split(this[_proxy].platform).join('{PLATFORM}') + .split(this[_proxy].arch).join('{ARCH}') + + return clean + } + + // test.afterEach hook + reset () { + this.removeAllListeners() + this[_parent] = undefined + this[_output] = [] + this[_data].clear() + this[_proxy].env = {} + this[_proxy].argv = [] + this[_npm] = undefined + } + + // test.teardown hook + teardown () { + if (this[_parent]) { + sandboxes.delete(this[_parent]) + } + return rimraf(this[_dirs].temp).catch(() => null) + } + + // proxy get handler + [_get] (target, prop, receiver) { + if (this[_data].has(prop)) { + return this[_data].get(prop) + } + + if (this[prop] !== undefined) { + return Reflect.get(this, prop, this) + } + + const actual = Reflect.get(target, prop, receiver) + if (typeof actual === 'function') { + // in node 10.1 there's an interesting bug where if a function on process + // is called without explicitly forcing the 'this' arg to something, we + // get 'Illegal invocation' errors. wrapping function properties in their + // own proxy so that we can make sure the context is right fixes it + return new Proxy(actual, { + apply: (target, context, args) => { + return Reflect.apply(target, _process, args) + }, + }) + } + + return actual + } + + // proxy set handler + [_set] (target, prop, value) { + if (prop === 'env') { + value = { + ...value, + HOME: this.home, + } + } + + if (prop === 'argv') { + value = [ + process.execPath, + join(dirname(process.execPath), 'npm'), + ...value, + ] + } + + return this[_data].set(prop, value) + } + + async run (command, argv = []) { + await Promise.all([ + mkdirp(this.project), + mkdirp(this.home), + mkdirp(this.global), + ]) + + // attach the sandbox process now, doing it after the promise above is + // necessary to make sure that only async calls spawned as part of this + // call to run will receive the sandbox. if we attach it too early, we + // end up interfering with tap + this[_parent] = executionAsyncId() + this[_data].set('_asyncId', this[_parent]) + sandboxes.set(this[_parent], this[_proxy]) + process = this[_proxy] + + this[_proxy].argv = [ + '--prefix', this.project, + '--userconfig', join(this.home, '.npmrc'), + '--globalconfig', join(this.global, 'npmrc'), + command, + ...argv, + ] + + this[_npm] = this[_test].mock('../../lib/npm.js', this[_mocks]) + this[_npm].output = (...args) => this[_output].push(args) + await this[_npm].load() + // in some node versions (later 10.x) our executionAsyncId at this point + // will for some reason appear to have been triggered by a different parent + // so immediately after load, if we can see that we lost our ancestry, we + // fix it here with a hammer + if (chain.get(executionAsyncId()) !== this[_parent]) { + chain.set(executionAsyncId(), this[_parent]) + process = this[_proxy] + } + + const cmd = this[_npm].argv.shift() + const impl = this[_npm].commands[cmd] + if (!impl) { + throw new Error(`Unknown command: ${cmd}`) + } + + return new Promise((resolve, reject) => { + impl(this[_npm].argv, (err) => { + if (err) { + return reject(err) + } + + return resolve() + }) + }) + } + + async complete (command, argv, partial) { + if (!Array.isArray(argv)) { + partial = argv + argv = [] + } + + await Promise.all([ + mkdirp(this.project), + mkdirp(this.home), + mkdirp(this.global), + ]) + + // attach the sandbox process now, doing it after the promise above is + // necessary to make sure that only async calls spawned as part of this + // call to run will receive the sandbox. if we attach it too early, we + // end up interfering with tap + this[_parent] = executionAsyncId() + this[_data].set('_asyncId', this[_parent]) + sandboxes.set(this[_parent], this[_proxy]) + process = this[_proxy] + + this[_proxy].argv = [ + '--prefix', this.project, + '--userconfig', join(this.home, '.npmrc'), + '--globalconfig', join(this.global, 'npmrc'), + command, + ...argv, + ] + + this[_npm] = this[_test].mock('../../lib/npm.js', this[_mocks]) + this[_npm].output = (...args) => this[_output].push(args) + await this[_npm].load() + // in some node versions (later 10.x) our executionAsyncId at this point + // will for some reason appear to have been triggered by a different parent + // so immediately after load, if we can see that we lost our ancestry, we + // fix it here with a hammer + if (chain.get(executionAsyncId()) !== this[_parent]) { + chain.set(executionAsyncId(), this[_parent]) + process = this[_proxy] + } + + const impl = this[_npm].commands[command] + if (!impl) { + throw new Error(`Unknown command: ${cmd}`) + } + + return impl.completion({ + partialWord: partial, + conf: { + argv: { + remain: ['npm', command, ...argv], + }, + }, + }) + } +} + +module.exports = Sandbox diff --git a/test/lib/config.js b/test/lib/config.js index 8a1e7d85e09aa..ba47fa11d0bbc 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -1,659 +1,379 @@ +const { join } = require('path') +const { promisify } = require('util') +const fs = require('fs') +const spawk = require('spawk') const t = require('tap') -const { EventEmitter } = require('events') - -const redactCwd = (path) => { - const normalizePath = p => p - .replace(/\\+/g, '/') - .replace(/\r\n/g, '\n') - const replaceCwd = p => p - .replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}') - const cleanupWinPaths = p => p - .replace(normalizePath(process.execPath), '/path/to/node') - .replace(normalizePath(process.env.HOME), '~/') - - return cleanupWinPaths( - replaceCwd( - normalizePath(path) - ) - ) -} - -t.cleanSnapshot = (str) => redactCwd(str) - -let result = '' - -const configDefs = require('../../lib/utils/config') -const definitions = Object.entries(configDefs.definitions) - .filter(([key, def]) => { - return [ - 'init-author-name', - 'init.author.name', - 'init-version', - 'init.version', - ].includes(key) - }).reduce((defs, [key, def]) => { - defs[key] = def - return defs - }, {}) - -const defaults = { - 'init-author-name': '', - 'init-version': '1.0.0', - 'init.author.name': '', - 'init.version': '1.0.0', -} - -const cliConfig = { - editor: 'vi', - json: false, - location: 'user', - long: false, - cat: true, - chai: true, - dog: true, -} - -const npm = { - log: { - warn: () => null, - info: () => null, - enableProgress: () => null, - disableProgress: () => null, - }, - config: { - data: new Map(Object.entries({ - default: { data: defaults, source: 'default values' }, - global: { data: {}, source: '/etc/npmrc' }, - cli: { data: cliConfig, source: 'command line options' }, - })), - get (key) { - return cliConfig[key] - }, - validate () { - return true - }, - }, - output: msg => { - result = msg - }, -} - -const usageUtil = () => 'usage instructions' - -const mocks = { - '../../lib/utils/config/index.js': { defaults, definitions }, - '../../lib/utils/usage.js': usageUtil, -} - -const Config = t.mock('../../lib/config.js', mocks) -const config = new Config(npm) - -t.test('config no args', t => { - config.exec([], (err) => { - t.match(err, /usage instructions/, 'should not error out on empty locations') - t.end() - }) -}) +spawk.preventUnmatched() -t.test('config ignores workspaces', t => { - npm.log.warn = (title, msg) => { - t.equal(title, 'config', 'should warn with expected title') - t.equal( - msg, - 'This command does not support workspaces.', - 'should warn with unsupported option msg' - ) - } - config.execWorkspaces([], [], (err) => { - t.match(err, /usage instructions/, 'should not error out when workspaces are defined') - npm.log.warn = () => null - t.end() - }) +const readFile = promisify(fs.readFile) + +const Sandbox = require('../fixtures/sandbox.js') + +t.test('config no args', async (t) => { + const sandbox = new Sandbox(t) + + await t.rejects(sandbox.run('config', []), { + code: 'EUSAGE', + }, 'rejects with usage') }) -t.test('config list', t => { - t.plan(2) +t.test('config ignores workspaces', async (t) => { + const sandbox = new Sandbox(t) - npm.config.find = () => 'cli' - result = '' - t.teardown(() => { - result = '' - delete npm.config.find - }) + await t.rejects(sandbox.run('config', ['--workspaces']), { + code: 'EUSAGE', + }, 'rejects with usage') - config.exec(['list'], (err) => { - t.error(err, 'npm config list') - t.matchSnapshot(result, 'should list configs') - }) + t.match(sandbox.logs.warn, [['config', 'This command does not support workspaces.']], 'logged the warning') }) -t.test('config list overrides', t => { - t.plan(2) +t.test('config list', async (t) => { + const sandbox = new Sandbox(t) - npm.config.data.set('user', { - data: { - 'init.author.name': 'Foo', - '//private-reg.npmjs.org/:_authThoken': 'f00ba1', + const temp = t.testdir({ + global: { + npmrc: 'globalloaded=yes', + }, + project: { + '.npmrc': 'projectloaded=yes', + }, + home: { + '.npmrc': 'userloaded=yes', }, - source: '~/.npmrc', - }) - cliConfig['init.author.name'] = 'Bar' - npm.config.find = () => 'cli' - result = '' - t.teardown(() => { - result = '' - npm.config.data.delete('user') - delete cliConfig['init.author.name'] - delete npm.config.find }) + const global = join(temp, 'global') + const project = join(temp, 'project') + const home = join(temp, 'home') - config.exec(['list'], (err) => { - t.error(err, 'npm config list') - t.matchSnapshot(result, 'should list overridden configs') - }) -}) + await sandbox.run('config', ['list'], { global, project, home }) -t.test('config list --long', t => { - t.plan(2) + t.matchSnapshot(sandbox.output, 'output matches snapshot') +}) - npm.config.find = key => key in cliConfig ? 'cli' : 'default' - cliConfig.long = true - result = '' - t.teardown(() => { - delete npm.config.find - cliConfig.long = false - result = '' +t.test('config list --long', async (t) => { + const temp = t.testdir({ + global: { + npmrc: 'globalloaded=yes', + }, + project: { + '.npmrc': 'projectloaded=yes', + }, + home: { + '.npmrc': 'userloaded=yes', + }, }) + const global = join(temp, 'global') + const project = join(temp, 'project') + const home = join(temp, 'home') - config.exec(['list'], (err) => { - t.error(err, 'npm config list --long') - t.matchSnapshot(result, 'should list all configs') - }) + const sandbox = new Sandbox(t, { global, project, home }) + await sandbox.run('config', ['list', '--long']) + + t.matchSnapshot(sandbox.output, 'output matches snapshot') }) -t.test('config list --json', t => { - t.plan(2) +t.test('config list --json', async (t) => { + const temp = t.testdir({ + global: { + npmrc: 'globalloaded=yes', + }, + project: { + '.npmrc': 'projectloaded=yes', + }, + home: { + '.npmrc': 'userloaded=yes', + }, + }) + const global = join(temp, 'global') + const project = join(temp, 'project') + const home = join(temp, 'home') - cliConfig.json = true - result = '' - npm.config.list = [{ - '//private-reg.npmjs.org/:_authThoken': 'f00ba1', - ...npm.config.data.get('cli').data, - }] - const npmConfigGet = npm.config.get - npm.config.get = key => npm.config.list[0][key] + const sandbox = new Sandbox(t, { global, project, home }) + await sandbox.run('config', ['list', '--json']) - t.teardown(() => { - delete npm.config.list - cliConfig.json = false - npm.config.get = npmConfigGet - result = '' - }) + t.matchSnapshot(sandbox.output, 'output matches snapshot') +}) - config.exec(['list'], (err) => { - t.error(err, 'npm config list --json') - t.same( - JSON.parse(result), - { - editor: 'vi', - json: true, - location: 'user', - long: false, - cat: true, - chai: true, - dog: true, - }, - 'should list configs usin json' - ) - }) +t.test('config delete no args', async (t) => { + const sandbox = new Sandbox(t) + + await t.rejects(sandbox.run('config', ['delete']), { + code: 'EUSAGE', + }, 'rejects with usage') }) -t.test('config delete no args', t => { - config.exec(['delete'], (err) => { - t.match(err, { message: '\nUsage: usage instructions' }) - t.end() +t.test('config delete single key', async (t) => { + // location defaults to user, so we work with a userconfig + const home = t.testdir({ + '.npmrc': 'foo=bar\nbar=baz', }) -}) -t.test('config delete key', t => { - t.plan(4) + const sandbox = new Sandbox(t) + await sandbox.run('config', ['delete', 'foo'], { home }) - npm.config.delete = (key, where) => { - t.equal(key, 'foo', 'should delete expected keyword') - t.equal(where, 'user', 'should delete key from user config by default') - } + t.equal(sandbox.config.get('foo'), undefined, 'foo should no longer be set') - npm.config.save = where => { - t.equal(where, 'user', 'should save user config post-delete') - } + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo='), 'foo was removed on disk') +}) - config.exec(['delete', 'foo'], (err) => { - t.error(err, 'npm config delete key') +t.test('config delete multiple keys', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar\nbar=baz\nbaz=buz', }) - t.teardown(() => { - delete npm.config.delete - delete npm.config.save - }) -}) + const sandbox = new Sandbox(t) + await sandbox.run('config', ['delete', 'foo', 'bar'], { home }) -t.test('config delete multiple key', t => { - t.plan(6) + t.equal(sandbox.config.get('foo'), undefined, 'foo should no longer be set') + t.equal(sandbox.config.get('bar'), undefined, 'bar should no longer be set') - const expect = [ - 'foo', - 'bar', - ] + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo='), 'foo was removed on disk') + t.not(contents.includes('bar='), 'bar was removed on disk') +}) - npm.config.delete = (key, where) => { - t.equal(key, expect.shift(), 'should delete expected keyword') - t.equal(where, 'user', 'should delete key from user config by default') - } +t.test('config delete key --location=global', async (t) => { + const global = t.testdir({ + npmrc: 'foo=bar\nbar=baz', + }) - npm.config.save = where => { - t.equal(where, 'user', 'should save user config post-delete') - } + const sandbox = new Sandbox(t) + await sandbox.run('config', ['delete', 'foo', '--location=global'], { global }) - config.exec(['delete', 'foo', 'bar'], (err) => { - t.error(err, 'npm config delete keys') - }) + t.equal(sandbox.config.get('foo', 'global'), undefined, 'foo should no longer be set') - t.teardown(() => { - delete npm.config.delete - delete npm.config.save - }) + const contents = await readFile(join(global, 'npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo='), 'foo was removed on disk') }) -t.test('config delete key --location=global', t => { - t.plan(4) +t.test('config delete key --global', async (t) => { + const global = t.testdir({ + npmrc: 'foo=bar\nbar=baz', + }) + + const sandbox = new Sandbox(t) + await sandbox.run('config', ['delete', 'foo', '--global'], { global }) - npm.config.delete = (key, where) => { - t.equal(key, 'foo', 'should delete expected keyword from global configs') - t.equal(where, 'global', 'should delete key from global config by default') - } + t.equal(sandbox.config.get('foo', 'global'), undefined, 'foo should no longer be set') - npm.config.save = where => { - t.equal(where, 'global', 'should save global config post-delete') - } + const contents = await readFile(join(global, 'npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo='), 'foo was removed on disk') +}) - cliConfig.location = 'global' - config.exec(['delete', 'foo'], (err) => { - t.error(err, 'npm config delete key --location=global') - }) +t.test('config set no args', async (t) => { + const sandbox = new Sandbox(t) - t.teardown(() => { - cliConfig.location = 'user' - delete npm.config.delete - delete npm.config.save - }) + await t.rejects(sandbox.run('config', ['set']), { + code: 'EUSAGE', + }, 'rejects with usage') }) -t.test('config set no args', t => { - config.exec(['set'], (err) => { - t.match(err, { message: '\nUsage: usage instructions' }) - t.end() +t.test('config set key', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar', }) -}) -t.test('config set key', t => { - t.plan(5) + const sandbox = new Sandbox(t, { home }) - npm.config.set = (key, val, where) => { - t.equal(key, 'foo', 'should set expected key to user config') - t.equal(val, 'bar', 'should set expected value to user config') - t.equal(where, 'user', 'should set key/val in user config by default') - } + await sandbox.run('config', ['set', 'foo']) - npm.config.save = where => { - t.equal(where, 'user', 'should save user config') - } + t.equal(sandbox.config.get('foo'), '', 'set the value for foo') - config.exec(['set', 'foo', 'bar'], (err) => { - t.error(err, 'npm config set key') - }) + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.ok(contents.includes('foo='), 'wrote foo to disk') +}) - t.teardown(() => { - delete npm.config.set - delete npm.config.save +t.test('config set key value', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar', }) -}) -t.test('config set key=val', t => { - t.plan(5) + const sandbox = new Sandbox(t, { home }) - npm.config.set = (key, val, where) => { - t.equal(key, 'foo', 'should set expected key to user config') - t.equal(val, 'bar', 'should set expected value to user config') - t.equal(where, 'user', 'should set key/val in user config by default') - } + await sandbox.run('config', ['set', 'foo', 'baz']) - npm.config.save = where => { - t.equal(where, 'user', 'should save user config') - } + t.equal(sandbox.config.get('foo'), 'baz', 'set the value for foo') - config.exec(['set', 'foo=bar'], (err) => { - t.error(err, 'npm config set key') - }) + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.ok(contents.includes('foo=baz'), 'wrote foo to disk') +}) - t.teardown(() => { - delete npm.config.set - delete npm.config.save +t.test('config set key=value', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar', }) -}) -t.test('config set multiple keys', t => { - t.plan(11) - - const expect = [ - ['foo', 'bar'], - ['bar', 'baz'], - ['asdf', ''], - ] - const args = ['foo', 'bar', 'bar=baz', 'asdf'] - - npm.config.set = (key, val, where) => { - const [expectKey, expectVal] = expect.shift() - t.equal(key, expectKey, 'should set expected key to user config') - t.equal(val, expectVal, 'should set expected value to user config') - t.equal(where, 'user', 'should set key/val in user config by default') - } + const sandbox = new Sandbox(t, { home }) - npm.config.save = where => { - t.equal(where, 'user', 'should save user config') - } + await sandbox.run('config', ['set', 'foo=baz']) - config.exec(['set', ...args], (err) => { - t.error(err, 'npm config set key') - }) + t.equal(sandbox.config.get('foo'), 'baz', 'set the value for foo') - t.teardown(() => { - delete npm.config.set - delete npm.config.save - }) + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.ok(contents.includes('foo=baz'), 'wrote foo to disk') }) -t.test('config set key to empty value', t => { - t.plan(5) - - npm.config.set = (key, val, where) => { - t.equal(key, 'foo', 'should set expected key to user config') - t.equal(val, '', 'should set "" to user config') - t.equal(where, 'user', 'should set key/val in user config by default') - } +t.test('config set key1 value1 key2=value2 key3', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar\nbar=baz\nbaz=foo', + }) - npm.config.save = where => { - t.equal(where, 'user', 'should save user config') - } + const sandbox = new Sandbox(t, { home }) + await sandbox.run('config', ['set', 'foo', 'oof', 'bar=rab', 'baz']) - config.exec(['set', 'foo'], (err) => { - t.error(err, 'npm config set key to empty value') - }) + t.equal(sandbox.config.get('foo'), 'oof', 'foo was set') + t.equal(sandbox.config.get('bar'), 'rab', 'bar was set') + t.equal(sandbox.config.get('baz'), '', 'baz was set') - t.teardown(() => { - delete npm.config.set - delete npm.config.save - }) + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.ok(contents.includes('foo=oof'), 'foo was written to disk') + t.ok(contents.includes('bar=rab'), 'bar was written to disk') + t.ok(contents.includes('baz='), 'baz was written to disk') }) -t.test('config set invalid key', t => { - t.plan(3) +t.test('config set invalid key logs warning', async (t) => { + const sandbox = new Sandbox(t) - const npmConfigValidate = npm.config.validate - npm.config.save = () => null - npm.config.set = () => null - npm.config.validate = () => false - npm.log.warn = (title, msg) => { - t.equal(title, 'config', 'should warn with expected title') - t.equal(msg, 'omitting invalid config values', 'should use expected msg') - } - t.teardown(() => { - npm.config.validate = npmConfigValidate - delete npm.config.save - delete npm.config.set - npm.log.warn = () => null - }) - - config.exec(['set', 'foo', 'bar'], (err) => { - t.error(err, 'npm config set invalid key') - }) + // this doesn't reject, it only logs a warning + await sandbox.run('config', ['set', 'access=foo']) + t.match(sandbox.logs.warn, [ + ['invalid config', 'access="foo"', `set in ${join(sandbox.home, '.npmrc')}`], + ], 'logged warning') }) -t.test('config set key --location=global', t => { - t.plan(5) +t.test('config set key=value --location=global', async (t) => { + const global = t.testdir({ + npmrc: 'foo=bar\nbar=baz', + }) - npm.config.set = (key, val, where) => { - t.equal(key, 'foo', 'should set expected key to global config') - t.equal(val, 'bar', 'should set expected value to global config') - t.equal(where, 'global', 'should set key/val in global config') - } + const sandbox = new Sandbox(t, { global }) + await sandbox.run('config', ['set', 'foo=buzz', '--location=global']) - npm.config.save = where => { - t.equal(where, 'global', 'should save global config') - } + t.equal(sandbox.config.get('foo', 'global'), 'buzz', 'foo should be set') - cliConfig.location = 'global' - config.exec(['set', 'foo', 'bar'], (err) => { - t.error(err, 'npm config set key --location=global') - }) + const contents = await readFile(join(global, 'npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo=buzz'), 'foo was saved on disk') +}) - t.teardown(() => { - cliConfig.location = 'user' - delete npm.config.set - delete npm.config.save +t.test('config set key=value --global', async (t) => { + const global = t.testdir({ + npmrc: 'foo=bar\nbar=baz', }) -}) -t.test('config get no args', t => { - t.plan(2) + const sandbox = new Sandbox(t, { global }) + await sandbox.run('config', ['set', 'foo=buzz', '--global']) - npm.config.find = () => 'cli' - result = '' - t.teardown(() => { - result = '' - delete npm.config.find - }) + t.equal(sandbox.config.get('foo', 'global'), 'buzz', 'foo should be set') - config.exec(['get'], (err) => { - t.error(err, 'npm config get no args') - t.matchSnapshot(result, 'should list configs on config get no args') - }) + const contents = await readFile(join(global, 'npmrc'), { encoding: 'utf8' }) + t.not(contents.includes('foo=buzz'), 'foo was saved on disk') }) -t.test('config get key', t => { - t.plan(2) +t.test('config get no args', async (t) => { + const sandbox = new Sandbox(t) - const npmConfigGet = npm.config.get - npm.config.get = (key) => { - t.equal(key, 'foo', 'should use expected key') - return 'bar' - } + await sandbox.run('config', ['get']) + const getOutput = sandbox.output - npm.config.save = where => { - throw new Error('should not save') - } + sandbox.reset() - config.exec(['get', 'foo'], (err) => { - t.error(err, 'npm config get key') - }) + await sandbox.run('config', ['list']) + const listOutput = sandbox.output - t.teardown(() => { - npm.config.get = npmConfigGet - delete npm.config.save - }) + t.equal(listOutput, getOutput, 'get with no args outputs list') }) -t.test('config get multiple keys', t => { - t.plan(4) - - const expect = [ - 'foo', - 'bar', - ] +t.test('config get single key', async (t) => { + const sandbox = new Sandbox(t) - const npmConfigGet = npm.config.get - npm.config.get = (key) => { - t.equal(key, expect.shift(), 'should use expected key') - return 'asdf' - } - - npm.config.save = where => { - throw new Error('should not save') - } + await sandbox.run('config', ['get', 'node-version']) + t.equal(sandbox.output, sandbox.config.get('node-version'), 'should get the value') +}) - config.exec(['get', 'foo', 'bar'], (err) => { - t.error(err, 'npm config get multiple keys') - t.equal(result, 'foo=asdf\nbar=asdf') - }) +t.test('config get multiple keys', async (t) => { + const sandbox = new Sandbox(t) - t.teardown(() => { - result = '' - npm.config.get = npmConfigGet - delete npm.config.save - }) + await sandbox.run('config', ['get', 'node-version', 'npm-version']) + t.ok(sandbox.output.includes(`node-version=${sandbox.config.get('node-version')}`), 'outputs node-version') + t.ok(sandbox.output.includes(`npm-version=${sandbox.config.get('npm-version')}`), 'outputs npm-version') }) -t.test('config get private key', t => { - config.exec(['get', '//private-reg.npmjs.org/:_authThoken'], (err) => { - t.match( - err, - /The \/\/private-reg.npmjs.org\/:_authThoken option is protected, and cannot be retrieved in this way/, - 'should throw unable to retrieve error' - ) - t.end() - }) +t.test('config get private key', async (t) => { + const sandbox = new Sandbox(t) + + await t.rejects(sandbox.run('config', ['get', '_authToken']), '_authToken is protected', 'rejects with protected string') }) -t.test('config edit', t => { - t.plan(12) - const npmrc = `//registry.npmjs.org/:_authToken=0000000 -init.author.name=Foo -sign-git-commit=true` - npm.config.data.set('user', { - source: '~/.npmrc', - }) - npm.config.save = async where => { - t.equal(where, 'user', 'should save to user config by default') - } - const editMocks = { - ...mocks, - 'mkdirp-infer-owner': async () => null, - fs: { - readFile (path, encoding, cb) { - cb(null, npmrc) - }, - writeFile (file, data, encoding, cb) { - t.equal(file, '~/.npmrc', 'should save to expected file location') - t.matchSnapshot(data, 'should write config file') - cb() - }, - }, - child_process: { - spawn: (bin, args) => { - t.equal(bin, 'vi', 'should use default editor') - t.strictSame(args, ['~/.npmrc'], 'should match user source data') - const ee = new EventEmitter() - process.nextTick(() => { - ee.emit('exit', 0) - }) - return ee - }, - }, - } - const Config = t.mock('../../lib/config.js', editMocks) - const config = new Config(npm) - - config.exec(['edit'], (err) => { - t.error(err, 'npm config edit') - - // test no config file result - editMocks.fs.readFile = (p, e, cb) => { - cb(new Error('ERR')) - } - const Config = t.mock('../../lib/config.js', editMocks) - const config = new Config(npm) - config.exec(['edit'], (err) => { - t.error(err, 'npm config edit') - }) +t.test('config edit', async (t) => { + const home = t.testdir({ + '.npmrc': 'foo=bar\nbar=baz', }) t.teardown(() => { - npm.config.data.delete('user') - delete npm.config.save + spawk.clean() }) -}) -t.test('config edit --location=global', t => { - t.plan(6) + const EDITOR = 'vim' + const editor = spawk.spawn(EDITOR).exit(0) - cliConfig.location = 'global' - const npmrc = 'init.author.name=Foo' - npm.config.data.set('global', { - source: '/etc/npmrc', - }) - npm.config.save = async where => { - t.equal(where, 'global', 'should save to global config') - } - const editMocks = { - ...mocks, - 'mkdirp-infer-owner': async () => null, - fs: { - readFile (path, encoding, cb) { - cb(null, npmrc) - }, - writeFile (file, data, encoding, cb) { - t.equal(file, '/etc/npmrc', 'should save to global file location') - t.matchSnapshot(data, 'should write global config file') - cb() - }, - }, - child_process: { - spawn: (bin, args, cb) => { - t.equal(bin, 'vi', 'should use default editor') - t.strictSame(args, ['/etc/npmrc'], 'should match global source data') - const ee = new EventEmitter() - process.nextTick(() => { - ee.emit('exit', 137) - }) - return ee - }, - }, - } - const Config = t.mock('../../lib/config.js', editMocks) - const config = new Config(npm) - config.exec(['edit'], (err) => { - t.match(err, /exited with code: 137/, 'propagated exit code from editor') - }) + const sandbox = new Sandbox(t, { home }) + sandbox.process.env.EDITOR = EDITOR + await sandbox.run('config', ['edit']) + + t.ok(editor.called, 'editor was spawned') + t.same(editor.calledWith.args, [join(sandbox.home, '.npmrc')], 'editor opened the user config file') + + const contents = await readFile(join(home, '.npmrc'), { encoding: 'utf8' }) + t.ok(contents.includes('foo=bar'), 'kept foo') + t.ok(contents.includes('bar=baz'), 'kept bar') + t.ok(contents.includes('shown below with default values'), 'appends defaults to file') +}) +t.test('config edit - editor exits non-0', async (t) => { t.teardown(() => { - cliConfig.location = 'user' - npm.config.data.delete('user') - delete npm.config.save + spawk.clean() }) -}) -t.test('completion', t => { - const { completion } = config + const EDITOR = 'vim' + const editor = spawk.spawn(EDITOR).exit(1) - const testComp = (argv, expect) => { - t.resolveMatch(completion({ conf: { argv: { remain: argv } } }), expect, argv.join(' ')) - } + const sandbox = new Sandbox(t) + sandbox.process.env.EDITOR = EDITOR + await t.rejects(sandbox.run('config', ['edit']), { + message: 'editor process exited with code: 1', + }, 'rejects with error about editor code') - testComp(['npm', 'foo'], []) - testComp(['npm', 'config'], ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'list']) - testComp(['npm', 'config', 'set', 'foo'], []) + t.ok(editor.called, 'editor was spawned') + t.same(editor.calledWith.args, [join(sandbox.home, '.npmrc')], 'editor opened the user config file') +}) - const possibleConfigKeys = [...Object.keys(definitions)] - testComp(['npm', 'config', 'get'], possibleConfigKeys) - testComp(['npm', 'config', 'set'], possibleConfigKeys) - testComp(['npm', 'config', 'delete'], possibleConfigKeys) - testComp(['npm', 'config', 'rm'], possibleConfigKeys) - testComp(['npm', 'config', 'edit'], []) - testComp(['npm', 'config', 'list'], []) - testComp(['npm', 'config', 'ls'], []) +t.test('completion', async (t) => { + const sandbox = new Sandbox(t) - const partial = completion({conf: { argv: { remain: ['npm', 'config'] } }, partialWord: 'l'}) - t.resolveMatch(partial, ['get', 'set', 'delete', 'ls', 'rm', 'edit'], 'npm config') + let allKeys + const testComp = async (argv, expect) => { + t.match(await sandbox.complete('config', argv), expect, argv.join(' ')) + if (!allKeys) + allKeys = Object.keys(sandbox.config.definitions) + sandbox.reset() + } - t.end() + await testComp([], ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'list']) + await testComp(['set', 'foo'], []) + await testComp(['get'], allKeys) + await testComp(['set'], allKeys) + await testComp(['delete'], allKeys) + await testComp(['rm'], allKeys) + await testComp(['edit'], []) + await testComp(['list'], []) + await testComp(['ls'], []) + + const getCommand = await sandbox.complete('get') + t.match(getCommand, allKeys, 'also works for just npm get') + sandbox.reset() + + const partial = await sandbox.complete('config', 'l') + t.match(partial, ['get', 'set', 'delete', 'ls', 'rm', 'edit'], 'and works on partials') }) diff --git a/test/lib/utils/config/definitions.js b/test/lib/utils/config/definitions.js index 63d9bbd195ab2..65193020d050c 100644 --- a/test/lib/utils/config/definitions.js +++ b/test/lib/utils/config/definitions.js @@ -812,19 +812,44 @@ t.test('location', t => { location: 'user', } const flat = {} + // the global flattener is what sets location, so run that + definitions.global.flatten('global', obj, flat) definitions.location.flatten('location', obj, flat) // global = true sets location in both places to global - t.strictSame(flat, { location: 'global' }) - t.strictSame(obj, { global: true, location: 'global' }) + t.strictSame(flat, { global: true, location: 'global' }) + // location here is still 'user' because flattening doesn't modify the object + t.strictSame(obj, { global: true, location: 'user' }) obj.global = false obj.location = 'user' delete flat.global delete flat.location + definitions.global.flatten('global', obj, flat) definitions.location.flatten('location', obj, flat) // global = false leaves location unaltered - t.strictSame(flat, { location: 'user' }) + t.strictSame(flat, { global: false, location: 'user' }) t.strictSame(obj, { global: false, location: 'user' }) t.end() }) + +t.test('package-lock-only', t => { + const obj = { + 'package-lock': false, + 'package-lock-only': true, + } + const flat = {} + + definitions['package-lock-only'].flatten('package-lock-only', obj, flat) + definitions['package-lock'].flatten('package-lock', obj, flat) + t.strictSame(flat, { packageLock: true, packageLockOnly: true }) + + obj['package-lock-only'] = false + delete flat.packageLock + delete flat.packageLockOnly + + definitions['package-lock-only'].flatten('package-lock-only', obj, flat) + definitions['package-lock'].flatten('package-lock', obj, flat) + t.strictSame(flat, { packageLock: false, packageLockOnly: false }) + t.end() +})