diff --git a/lib/commands/ci.js b/lib/commands/ci.js index 7e79d7208c9c4..4432aea408fbd 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -1,9 +1,11 @@ const reifyFinish = require('../utils/reify-finish.js') const runScript = require('@npmcli/run-script') const fs = require('fs/promises') +const path = require('path') const { log, time } = require('proc-log') const validateLockfile = require('../utils/validate-lockfile.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') +const getWorkspaces = require('../utils/get-workspaces.js') class CI extends ArboristWorkspaceCmd { static description = 'Clean install a project' @@ -76,14 +78,22 @@ class CI extends ArboristWorkspaceCmd { const dryRun = this.npm.config.get('dry-run') if (!dryRun) { + const workspacePaths = await getWorkspaces([], { + path: this.npm.localPrefix, + includeWorkspaceRoot: true, + }) + // Only remove node_modules after we've successfully loaded the virtual // tree and validated the lockfile await time.start('npm-ci:rm', async () => { - const path = `${where}/node_modules` - // get the list of entries so we can skip the glob for performance - const entries = await fs.readdir(path, null).catch(() => []) - return Promise.all(entries.map(f => fs.rm(`${path}/${f}`, - { force: true, recursive: true }))) + return await Promise.all([...workspacePaths.values()].map(async modulePath => { + const fullPath = path.join(modulePath, 'node_modules') + // get the list of entries so we can skip the glob for performance + const entries = await fs.readdir(fullPath, null).catch(() => []) + return Promise.all(entries.map(folder => { + return fs.rm(path.join(fullPath, folder), { force: true, recursive: true }) + })) + })) }) } diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 727b37e675fc3..8b7ffdef83f35 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -451,6 +451,50 @@ class MockRegistry { ...packument, } } + + /** + * this is a simpler convience method for creating mockable registry with + * tarballs for specific versions + */ + async setup (packages) { + const format = Object.keys(packages).map(v => { + const [name, version] = v.split('@') + return { name, version } + }).reduce((acc, inc) => { + const exists = acc.find(pkg => pkg.name === inc.name) + if (exists) { + exists.tarballs = { + ...exists.tarballs, + [inc.version]: packages[`${inc.name}@${inc.version}`], + } + } else { + acc.push({ name: inc.name, + tarballs: { + [inc.version]: packages[`${inc.name}@${inc.version}`], + }, + }) + } + return acc + }, []) + const registry = this + for (const pkg of format) { + const { name, tarballs } = pkg + const versions = Object.keys(tarballs) + const manifest = await registry.manifest({ name, versions }) + + for (const version of versions) { + const tarballPath = pkg.tarballs[version] + if (!tarballPath) { + throw new Error(`Tarball path not provided for version ${version}`) + } + + await registry.tarball({ + manifest: manifest.versions[version], + tarball: tarballPath, + }) + } + } + } } module.exports = MockRegistry diff --git a/test/fixtures/mock-npm.js b/test/fixtures/mock-npm.js index 674147781af4c..e1003e8da068a 100644 --- a/test/fixtures/mock-npm.js +++ b/test/fixtures/mock-npm.js @@ -1,10 +1,12 @@ const os = require('os') const fs = require('fs').promises +const fsSync = require('fs') const path = require('path') const tap = require('tap') const mockLogs = require('./mock-logs.js') const mockGlobals = require('@npmcli/mock-globals') const tmock = require('./tmock') +const MockRegistry = require('@npmcli/mock-registry') const defExitCode = process.exitCode const changeDir = (dir) => { @@ -288,6 +290,167 @@ const setupMockNpm = async (t, { } } +const loadNpmWithRegistry = async (t, opts) => { + const mock = await setupMockNpm(t, opts) + const registry = new MockRegistry({ + tap: t, + registry: mock.npm.config.get('registry'), + strict: true, + }) + + const fileShouldExist = (filePath) => { + t.equal( + fsSync.existsSync(path.join(mock.npm.prefix, filePath)), true, `${filePath} should exist` + ) + } + + const fileShouldNotExist = (filePath) => { + t.equal( + fsSync.existsSync(path.join(mock.npm.prefix, filePath)), false, `${filePath} should not exist` + ) + } + + const packageVersionMatches = (filePath, version) => { + t.equal( + JSON.parse(fsSync.readFileSync(path.join(mock.npm.prefix, filePath), 'utf8')).version, version + ) + } + + const packageInstalled = (target) => { + const spec = path.basename(target) + const dirname = path.dirname(target) + const [name, version = '1.0.0'] = spec.split('@') + fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`) + packageVersionMatches(`${dirname}/${name}/package.json`, version) + fileShouldExist(`${dirname}/${name}/index.js`) + } + + const packageMissing = (target) => { + const spec = path.basename(target) + const dirname = path.dirname(target) + const [name, version = '1.0.0'] = spec.split('@') + fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`) + fileShouldNotExist(`${dirname}/${name}/package.json`) + fileShouldNotExist(`${dirname}/${name}/index.js`) + } + + const packageDirty = (target) => { + const spec = path.basename(target) + const dirname = path.dirname(target) + const [name, version = '1.0.0'] = spec.split('@') + fileShouldExist(`${dirname}/${name}/${name}@${version}.txt`) + packageVersionMatches(`${dirname}/${name}/package.json`, version) + fileShouldNotExist(`${dirname}/${name}/index.js`) + } + + const assert = { + fileShouldExist, + fileShouldNotExist, + packageVersionMatches, + packageInstalled, + packageMissing, + packageDirty, + } + + return { registry, assert, ...mock } +} + +/** breaks down a spec "abbrev@1.1.1" into different parts for mocking */ +function dependencyDetails (spec, opt = {}) { + const [name, version = '1.0.0'] = spec.split('@') + const { parent, hoist = true, ws, clean = true } = opt + const modulePathPrefix = !hoist && parent ? `${parent}/` : '' + const modulePath = `${modulePathPrefix}node_modules/${name}` + const resolved = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz` + // deps + const wsEntries = Object.entries({ ...ws }) + const depsMap = wsEntries.map(([s, o]) => dependencyDetails(s, { ...o, parent: name })) + const dependencies = Object.assign({}, ...depsMap.map(d => d.packageDependency)) + const spreadDependencies = depsMap.length ? { dependencies } : {} + // package and lock objects + const packageDependency = { [name]: version } + const packageLockEntry = { [modulePath]: { version, resolved } } + const packageLockLink = { [modulePath]: { resolved: name, link: true } } + const packageLockLocal = { [name]: { version, dependencies } } + // build package.js + const packageJSON = { name, version, ...spreadDependencies } + const packageJSONString = JSON.stringify(packageJSON) + const packageJSONFile = { 'package.json': packageJSONString } + // build index.js + const indexJSString = 'module.exports = "hello world"' + const indexJSFile = { 'index.js': indexJSString } + // tarball + const packageFiles = { ...packageJSONFile, ...indexJSFile } + const nodeModules = Object.assign({}, ...depsMap.map(d => d.hoist ? {} : d.dirtyOrCleanDir)) + const nodeModulesDir = { node_modules: nodeModules } + const packageDir = { [name]: { ...packageFiles, ...nodeModulesDir } } + const tarballDir = { [`${name}@${version}`]: packageFiles } + // dirty files + const dirtyFile = { [`${name}@${version}.txt`]: 'dirty file' } + const dirtyFiles = { ...packageJSONFile, ...dirtyFile } + const dirtyDir = { [name]: dirtyFiles } + const dirtyOrCleanDir = clean ? {} : dirtyDir + + return { + packageDependency, + hoist, + depsMap, + dirtyOrCleanDir, + tarballDir, + packageDir, + packageLockEntry, + packageLockLink, + packageLockLocal, + } +} + +function workspaceMock (t, opts) { + const toObject = [(a, c) => ({ ...a, ...c }), {}] + const { workspaces: workspacesDef, ...rest } = { clean: true, ...opts } + const workspaces = Object.fromEntries(Object.entries(workspacesDef).map(([name, ws]) => { + return [name, Object.fromEntries(Object.entries(ws).map(([wsPackageDep, wsPackageDepOpts]) => { + return [wsPackageDep, { ...rest, ...wsPackageDepOpts }] + }))] + })) + const root = 'workspace-root' + const version = '1.0.0' + const names = Object.keys(workspaces) + const ws = Object.entries(workspaces).map(([name, _ws]) => dependencyDetails(name, { ws: _ws })) + const deps = ws.map(({ depsMap }) => depsMap).flat() + const tarballs = deps.map(w => w.tarballDir).reduce(...toObject) + const symlinks = names + .map((name) => ({ [name]: t.fixture('symlink', `../${name}`) })).reduce(...toObject) + const hoisted = deps.filter(d => d.hoist).map(w => w.dirtyOrCleanDir).reduce(...toObject) + const workspaceFolders = ws.map(w => w.packageDir).reduce(...toObject) + const packageJSON = { name: root, version, workspaces: names } + const packageLockJSON = ({ + name: root, + version, + lockfileVersion: 3, + requires: true, + packages: { + '': { name: root, version, workspaces: names }, + ...deps.filter(d => d.hoist).map(d => d.packageLockEntry).reduce(...toObject), + ...ws.map(d => d.packageLockEntry).flat().reduce(...toObject), + ...ws.map(d => d.packageLockLink).flat().reduce(...toObject), + ...ws.map(d => d.packageLockLocal).flat().reduce(...toObject), + ...deps.filter(d => !d.hoist).map(d => d.packageLockEntry).reduce(...toObject), + }, + }) + return { + tarballs, + node_modules: { + ...hoisted, + ...symlinks, + }, + 'package-lock.json': JSON.stringify(packageLockJSON), + 'package.json': JSON.stringify(packageJSON), + ...workspaceFolders, + } +} + module.exports = setupMockNpm module.exports.load = setupMockNpm module.exports.setGlobalNodeModules = setGlobalNodeModules +module.exports.loadNpmWithRegistry = loadNpmWithRegistry +module.exports.workspaceMock = workspaceMock diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index c4b855932a9ed..bcca4876fce79 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -1,21 +1,14 @@ +const fs = require('fs') +const path = require('path') const t = require('tap') -const { load: _loadMockNpm } = require('../../fixtures/mock-npm') -const MockRegistry = require('@npmcli/mock-registry') -const path = require('path') -const fs = require('fs') +const { + loadNpmWithRegistry: loadMockNpm, + workspaceMock, +} = require('../../fixtures/mock-npm') // t.cleanSnapshot = str => str.replace(/ in [0-9ms]+/g, ' in {TIME}') -const loadMockNpm = async (t, opts) => { - const mock = await _loadMockNpm(t, opts) - const registry = new MockRegistry({ - tap: t, - registry: mock.npm.config.get('registry'), - }) - return { registry, ...mock } -} - const packageJson = { name: 'test-package', version: '1.0.0', @@ -109,11 +102,17 @@ t.test('reifies, audits, removes node_modules on repeat run', async t => { }, }) const manifest = registry.manifest({ name: 'abbrev' }) + await registry.tarball({ + times: 2, + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'abbrev'), + }) await registry.tarball({ manifest: manifest.versions['1.0.0'], tarball: path.join(npm.prefix, 'abbrev'), }) registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) await npm.exec('ci', []) await npm.exec('ci', []) t.match(joinedOutput(), 'added 1 package, and audited 2 packages in') @@ -142,9 +141,6 @@ t.test('--no-audit and --ignore-scripts', async t => { 'package-lock.json': JSON.stringify(packageLock), }, }) - require('nock').emitter.on('no match', () => { - t.fail('Should not audit') - }) const manifest = registry.manifest({ name: 'abbrev' }) await registry.tarball({ manifest: manifest.versions['1.0.0'], @@ -230,3 +226,85 @@ t.test('should throw error when ideal inventory mismatches virtual', async t => const nmTestFile = path.join(npm.prefix, 'node_modules', 'test-file') t.equal(fs.existsSync(nmTestFile), true, 'does not remove node_modules') }) + +t.test('should remove dirty node_modules with unhoisted workspace module', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + prefixDir: workspaceMock(t, { + clean: false, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { }, + }, + 'workspace-b': { + 'abbrev@1.1.1': { hoist: false }, + }, + }, + }), + }) + await registry.setup({ + 'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'), + 'abbrev@1.1.1': path.join(npm.prefix, 'tarballs/abbrev@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageDirty('node_modules/abbrev@1.1.0') + assert.packageDirty('workspace-b/node_modules/abbrev@1.1.1') + await npm.exec('ci', []) + assert.packageInstalled('node_modules/abbrev@1.1.0') + assert.packageInstalled('workspace-b/node_modules/abbrev@1.1.1') +}) + +t.test('should remove dirty node_modules with hoisted workspace modules', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + prefixDir: workspaceMock(t, { + clean: false, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { }, + }, + 'workspace-b': { + 'lodash@1.1.1': { }, + }, + }, + }), + }) + await registry.setup({ + 'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'), + 'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageDirty('node_modules/abbrev@1.1.0') + assert.packageDirty('node_modules/lodash@1.1.1') + await npm.exec('ci', []) + assert.packageInstalled('node_modules/abbrev@1.1.0') + assert.packageInstalled('node_modules/lodash@1.1.1') +}) + +/** this behaves the same way as install but will remove all workspace node_modules */ +t.test('should use --workspace flag', async t => { + t.saveFixture = true + const { npm, registry, assert } = await loadMockNpm(t, { + config: { + workspace: 'workspace-b', + }, + prefixDir: workspaceMock(t, { + clean: false, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { }, + }, + 'workspace-b': { + 'lodash@1.1.1': { }, + }, + }, + }), + }) + await registry.setup({ + 'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageDirty('node_modules/abbrev@1.1.0') + assert.packageDirty('node_modules/lodash@1.1.1') + await npm.exec('ci', []) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageInstalled('node_modules/lodash@1.1.1') +}) diff --git a/test/lib/commands/install.js b/test/lib/commands/install.js index 8bb84bfff581f..4fa234df99bea 100644 --- a/test/lib/commands/install.js +++ b/test/lib/commands/install.js @@ -1,9 +1,14 @@ -const t = require('tap') const tspawk = require('../../fixtures/tspawk') -const MockRegistry = require('@npmcli/mock-registry') -const { load: loadMockNpm } = require('../../fixtures/mock-npm') -const path = require('node:path') +const path = require('path') +const t = require('tap') + +const { + loadNpmWithRegistry: loadMockNpm, + workspaceMock, +} = require('../../fixtures/mock-npm') + +// tspawk calls preventUnmatched which assures that no scripts run if we don't mock any const spawk = tspawk(t) const abbrev = { @@ -19,11 +24,9 @@ const packageJson = { }, } -// tspawk calls preventUnmatched which assures that no scripts run if we don't mock any - t.test('exec commands', async t => { await t.test('with args does not run lifecycle scripts', async t => { - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { config: { audit: false, }, @@ -37,11 +40,6 @@ t.test('exec commands', async t => { abbrev, }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) - const manifest = registry.manifest({ name: 'abbrev' }) await registry.package({ manifest }) await registry.tarball({ @@ -70,7 +68,7 @@ t.test('exec commands', async t => { }) scripts[script] = `${script} lifecycle script` } - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { config: { audit: false, }, @@ -83,10 +81,6 @@ t.test('exec commands', async t => { }, }) const runOrder = [] - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) const manifest = registry.manifest({ name: 'abbrev' }) await registry.package({ manifest }) await registry.tarball({ @@ -99,7 +93,7 @@ t.test('exec commands', async t => { }) await t.test('should ignore scripts with --ignore-scripts', async t => { - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false, @@ -114,11 +108,6 @@ t.test('exec commands', async t => { abbrev, }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) - const manifest = registry.manifest({ name: 'abbrev' }) await registry.package({ manifest }) await registry.tarball({ @@ -143,7 +132,7 @@ t.test('exec commands', async t => { }) await t.test('npm i -g npm engines check success', async t => { - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { prefixDir: { npm: { 'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }), @@ -152,10 +141,6 @@ t.test('exec commands', async t => { }, config: { global: true }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) const manifest = registry.manifest({ name: 'npm', packuments: [{ version: '1.0.0', engines: { node: '>1' } }], @@ -170,7 +155,7 @@ t.test('exec commands', async t => { }) await t.test('npm i -g npm engines check failure', async t => { - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { prefixDir: { npm: { 'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }), @@ -179,10 +164,7 @@ t.test('exec commands', async t => { }, config: { global: true }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) + const manifest = registry.manifest({ name: 'npm', packuments: [{ version: '1.0.0', engines: { node: '~1' } }], @@ -206,7 +188,7 @@ t.test('exec commands', async t => { }) await t.test('npm i -g npm engines check failure forced override', async t => { - const { npm } = await loadMockNpm(t, { + const { npm, registry } = await loadMockNpm(t, { prefixDir: { npm: { 'package.json': JSON.stringify({ name: 'npm', version: '1.0.0' }), @@ -215,10 +197,6 @@ t.test('exec commands', async t => { }, config: { global: true, force: true }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - }) const manifest = registry.manifest({ name: 'npm', packuments: [{ version: '1.0.0', engines: { node: '~1' } }], @@ -288,3 +266,137 @@ t.test('completion', async t => { t.strictSame(res, []) }) }) + +t.test('should install in workspace with unhoisted module', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + prefixDir: workspaceMock(t, { + clean: true, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { hoist: true }, + }, + 'workspace-b': { + 'abbrev@1.1.1': { hoist: false }, + }, + }, + }), + }) + await registry.setup({ + 'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'), + 'abbrev@1.1.1': path.join(npm.prefix, 'tarballs/abbrev@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageMissing('workspace-b/node_modules/abbrev@1.1.1') + await npm.exec('install', []) + assert.packageInstalled('node_modules/abbrev@1.1.0') + assert.packageInstalled('workspace-b/node_modules/abbrev@1.1.1') +}) + +t.test('should install in workspace with hoisted modules', async t => { + const prefixDir = workspaceMock(t, { + clean: true, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { hoist: true }, + }, + 'workspace-b': { + 'lodash@1.1.1': { hoist: true }, + }, + }, + }) + const { npm, registry, assert } = await loadMockNpm(t, { prefixDir }) + await registry.setup({ + 'abbrev@1.1.0': path.join(npm.prefix, 'tarballs/abbrev@1.1.0'), + 'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageMissing('node_modules/lodash@1.1.1') + await npm.exec('install', []) + assert.packageInstalled('node_modules/abbrev@1.1.0') + assert.packageInstalled('node_modules/lodash@1.1.1') +}) + +t.test('should install unhoisted module with --workspace flag', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + config: { + workspace: 'workspace-b', + }, + prefixDir: workspaceMock(t, { + clean: true, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { hoist: true }, + }, + 'workspace-b': { + 'abbrev@1.1.1': { hoist: false }, + }, + }, + }), + }) + await registry.setup({ + 'abbrev@1.1.1': path.join(npm.prefix, 'tarballs/abbrev@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageMissing('workspace-b/node_modules/abbrev@1.1.1') + await npm.exec('install', []) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageInstalled('workspace-b/node_modules/abbrev@1.1.1') +}) + +t.test('should install hoisted module with --workspace flag', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + config: { + workspace: 'workspace-b', + }, + prefixDir: workspaceMock(t, { + clean: true, + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { hoist: true }, + }, + 'workspace-b': { + 'lodash@1.1.1': { hoist: true }, + }, + }, + }), + }) + await registry.setup({ + 'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageMissing('node_modules/lodash@1.1.1') + await npm.exec('install', []) + assert.packageMissing('node_modules/abbrev@1.1.0') + assert.packageInstalled('node_modules/lodash@1.1.1') +}) + +t.test('should show install keeps dirty --workspace flag', async t => { + const { npm, registry, assert } = await loadMockNpm(t, { + config: { + workspace: 'workspace-b', + }, + prefixDir: workspaceMock(t, { + workspaces: { + 'workspace-a': { + 'abbrev@1.1.0': { clean: false, hoist: true }, + }, + 'workspace-b': { + 'lodash@1.1.1': { clean: true, hoist: true }, + }, + }, + }), + }) + await registry.setup({ + 'lodash@1.1.1': path.join(npm.prefix, 'tarballs/lodash@1.1.1'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + assert.packageDirty('node_modules/abbrev@1.1.0') + assert.packageMissing('node_modules/lodash@1.1.1') + await npm.exec('install', []) + assert.packageDirty('node_modules/abbrev@1.1.0') + assert.packageInstalled('node_modules/lodash@1.1.1') +})