Skip to content

Commit 7d89b55

Browse files
authored
fix(ci): rm workspace node_modules (#7490)
1 parent 9122fb6 commit 7d89b55

File tree

5 files changed

+466
-59
lines changed

5 files changed

+466
-59
lines changed

lib/commands/ci.js

+15-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const reifyFinish = require('../utils/reify-finish.js')
22
const runScript = require('@npmcli/run-script')
33
const fs = require('fs/promises')
4+
const path = require('path')
45
const { log, time } = require('proc-log')
56
const validateLockfile = require('../utils/validate-lockfile.js')
67
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
8+
const getWorkspaces = require('../utils/get-workspaces.js')
79

810
class CI extends ArboristWorkspaceCmd {
911
static description = 'Clean install a project'
@@ -76,14 +78,22 @@ class CI extends ArboristWorkspaceCmd {
7678

7779
const dryRun = this.npm.config.get('dry-run')
7880
if (!dryRun) {
81+
const workspacePaths = await getWorkspaces([], {
82+
path: this.npm.localPrefix,
83+
includeWorkspaceRoot: true,
84+
})
85+
7986
// Only remove node_modules after we've successfully loaded the virtual
8087
// tree and validated the lockfile
8188
await time.start('npm-ci:rm', async () => {
82-
const path = `${where}/node_modules`
83-
// get the list of entries so we can skip the glob for performance
84-
const entries = await fs.readdir(path, null).catch(() => [])
85-
return Promise.all(entries.map(f => fs.rm(`${path}/${f}`,
86-
{ force: true, recursive: true })))
89+
return await Promise.all([...workspacePaths.values()].map(async modulePath => {
90+
const fullPath = path.join(modulePath, 'node_modules')
91+
// get the list of entries so we can skip the glob for performance
92+
const entries = await fs.readdir(fullPath, null).catch(() => [])
93+
return Promise.all(entries.map(folder => {
94+
return fs.rm(path.join(fullPath, folder), { force: true, recursive: true })
95+
}))
96+
}))
8797
})
8898
}
8999

mock-registry/lib/index.js

+44
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,50 @@ class MockRegistry {
451451
...packument,
452452
}
453453
}
454+
455+
/**
456+
* this is a simpler convience method for creating mockable registry with
457+
* tarballs for specific versions
458+
*/
459+
async setup (packages) {
460+
const format = Object.keys(packages).map(v => {
461+
const [name, version] = v.split('@')
462+
return { name, version }
463+
}).reduce((acc, inc) => {
464+
const exists = acc.find(pkg => pkg.name === inc.name)
465+
if (exists) {
466+
exists.tarballs = {
467+
...exists.tarballs,
468+
[inc.version]: packages[`${inc.name}@${inc.version}`],
469+
}
470+
} else {
471+
acc.push({ name: inc.name,
472+
tarballs: {
473+
[inc.version]: packages[`${inc.name}@${inc.version}`],
474+
},
475+
})
476+
}
477+
return acc
478+
}, [])
479+
const registry = this
480+
for (const pkg of format) {
481+
const { name, tarballs } = pkg
482+
const versions = Object.keys(tarballs)
483+
const manifest = await registry.manifest({ name, versions })
484+
485+
for (const version of versions) {
486+
const tarballPath = pkg.tarballs[version]
487+
if (!tarballPath) {
488+
throw new Error(`Tarball path not provided for version ${version}`)
489+
}
490+
491+
await registry.tarball({
492+
manifest: manifest.versions[version],
493+
tarball: tarballPath,
494+
})
495+
}
496+
}
497+
}
454498
}
455499

456500
module.exports = MockRegistry

test/fixtures/mock-npm.js

+163
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
const os = require('os')
22
const fs = require('fs').promises
3+
const fsSync = require('fs')
34
const path = require('path')
45
const tap = require('tap')
56
const mockLogs = require('./mock-logs.js')
67
const mockGlobals = require('@npmcli/mock-globals')
78
const tmock = require('./tmock')
9+
const MockRegistry = require('@npmcli/mock-registry')
810
const defExitCode = process.exitCode
911

1012
const changeDir = (dir) => {
@@ -288,6 +290,167 @@ const setupMockNpm = async (t, {
288290
}
289291
}
290292

293+
const loadNpmWithRegistry = async (t, opts) => {
294+
const mock = await setupMockNpm(t, opts)
295+
const registry = new MockRegistry({
296+
tap: t,
297+
registry: mock.npm.config.get('registry'),
298+
strict: true,
299+
})
300+
301+
const fileShouldExist = (filePath) => {
302+
t.equal(
303+
fsSync.existsSync(path.join(mock.npm.prefix, filePath)), true, `${filePath} should exist`
304+
)
305+
}
306+
307+
const fileShouldNotExist = (filePath) => {
308+
t.equal(
309+
fsSync.existsSync(path.join(mock.npm.prefix, filePath)), false, `${filePath} should not exist`
310+
)
311+
}
312+
313+
const packageVersionMatches = (filePath, version) => {
314+
t.equal(
315+
JSON.parse(fsSync.readFileSync(path.join(mock.npm.prefix, filePath), 'utf8')).version, version
316+
)
317+
}
318+
319+
const packageInstalled = (target) => {
320+
const spec = path.basename(target)
321+
const dirname = path.dirname(target)
322+
const [name, version = '1.0.0'] = spec.split('@')
323+
fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`)
324+
packageVersionMatches(`${dirname}/${name}/package.json`, version)
325+
fileShouldExist(`${dirname}/${name}/index.js`)
326+
}
327+
328+
const packageMissing = (target) => {
329+
const spec = path.basename(target)
330+
const dirname = path.dirname(target)
331+
const [name, version = '1.0.0'] = spec.split('@')
332+
fileShouldNotExist(`${dirname}/${name}/${name}@${version}.txt`)
333+
fileShouldNotExist(`${dirname}/${name}/package.json`)
334+
fileShouldNotExist(`${dirname}/${name}/index.js`)
335+
}
336+
337+
const packageDirty = (target) => {
338+
const spec = path.basename(target)
339+
const dirname = path.dirname(target)
340+
const [name, version = '1.0.0'] = spec.split('@')
341+
fileShouldExist(`${dirname}/${name}/${name}@${version}.txt`)
342+
packageVersionMatches(`${dirname}/${name}/package.json`, version)
343+
fileShouldNotExist(`${dirname}/${name}/index.js`)
344+
}
345+
346+
const assert = {
347+
fileShouldExist,
348+
fileShouldNotExist,
349+
packageVersionMatches,
350+
packageInstalled,
351+
packageMissing,
352+
packageDirty,
353+
}
354+
355+
return { registry, assert, ...mock }
356+
}
357+
358+
/** breaks down a spec "abbrev@1.1.1" into different parts for mocking */
359+
function dependencyDetails (spec, opt = {}) {
360+
const [name, version = '1.0.0'] = spec.split('@')
361+
const { parent, hoist = true, ws, clean = true } = opt
362+
const modulePathPrefix = !hoist && parent ? `${parent}/` : ''
363+
const modulePath = `${modulePathPrefix}node_modules/${name}`
364+
const resolved = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`
365+
// deps
366+
const wsEntries = Object.entries({ ...ws })
367+
const depsMap = wsEntries.map(([s, o]) => dependencyDetails(s, { ...o, parent: name }))
368+
const dependencies = Object.assign({}, ...depsMap.map(d => d.packageDependency))
369+
const spreadDependencies = depsMap.length ? { dependencies } : {}
370+
// package and lock objects
371+
const packageDependency = { [name]: version }
372+
const packageLockEntry = { [modulePath]: { version, resolved } }
373+
const packageLockLink = { [modulePath]: { resolved: name, link: true } }
374+
const packageLockLocal = { [name]: { version, dependencies } }
375+
// build package.js
376+
const packageJSON = { name, version, ...spreadDependencies }
377+
const packageJSONString = JSON.stringify(packageJSON)
378+
const packageJSONFile = { 'package.json': packageJSONString }
379+
// build index.js
380+
const indexJSString = 'module.exports = "hello world"'
381+
const indexJSFile = { 'index.js': indexJSString }
382+
// tarball
383+
const packageFiles = { ...packageJSONFile, ...indexJSFile }
384+
const nodeModules = Object.assign({}, ...depsMap.map(d => d.hoist ? {} : d.dirtyOrCleanDir))
385+
const nodeModulesDir = { node_modules: nodeModules }
386+
const packageDir = { [name]: { ...packageFiles, ...nodeModulesDir } }
387+
const tarballDir = { [`${name}@${version}`]: packageFiles }
388+
// dirty files
389+
const dirtyFile = { [`${name}@${version}.txt`]: 'dirty file' }
390+
const dirtyFiles = { ...packageJSONFile, ...dirtyFile }
391+
const dirtyDir = { [name]: dirtyFiles }
392+
const dirtyOrCleanDir = clean ? {} : dirtyDir
393+
394+
return {
395+
packageDependency,
396+
hoist,
397+
depsMap,
398+
dirtyOrCleanDir,
399+
tarballDir,
400+
packageDir,
401+
packageLockEntry,
402+
packageLockLink,
403+
packageLockLocal,
404+
}
405+
}
406+
407+
function workspaceMock (t, opts) {
408+
const toObject = [(a, c) => ({ ...a, ...c }), {}]
409+
const { workspaces: workspacesDef, ...rest } = { clean: true, ...opts }
410+
const workspaces = Object.fromEntries(Object.entries(workspacesDef).map(([name, ws]) => {
411+
return [name, Object.fromEntries(Object.entries(ws).map(([wsPackageDep, wsPackageDepOpts]) => {
412+
return [wsPackageDep, { ...rest, ...wsPackageDepOpts }]
413+
}))]
414+
}))
415+
const root = 'workspace-root'
416+
const version = '1.0.0'
417+
const names = Object.keys(workspaces)
418+
const ws = Object.entries(workspaces).map(([name, _ws]) => dependencyDetails(name, { ws: _ws }))
419+
const deps = ws.map(({ depsMap }) => depsMap).flat()
420+
const tarballs = deps.map(w => w.tarballDir).reduce(...toObject)
421+
const symlinks = names
422+
.map((name) => ({ [name]: t.fixture('symlink', `../${name}`) })).reduce(...toObject)
423+
const hoisted = deps.filter(d => d.hoist).map(w => w.dirtyOrCleanDir).reduce(...toObject)
424+
const workspaceFolders = ws.map(w => w.packageDir).reduce(...toObject)
425+
const packageJSON = { name: root, version, workspaces: names }
426+
const packageLockJSON = ({
427+
name: root,
428+
version,
429+
lockfileVersion: 3,
430+
requires: true,
431+
packages: {
432+
'': { name: root, version, workspaces: names },
433+
...deps.filter(d => d.hoist).map(d => d.packageLockEntry).reduce(...toObject),
434+
...ws.map(d => d.packageLockEntry).flat().reduce(...toObject),
435+
...ws.map(d => d.packageLockLink).flat().reduce(...toObject),
436+
...ws.map(d => d.packageLockLocal).flat().reduce(...toObject),
437+
...deps.filter(d => !d.hoist).map(d => d.packageLockEntry).reduce(...toObject),
438+
},
439+
})
440+
return {
441+
tarballs,
442+
node_modules: {
443+
...hoisted,
444+
...symlinks,
445+
},
446+
'package-lock.json': JSON.stringify(packageLockJSON),
447+
'package.json': JSON.stringify(packageJSON),
448+
...workspaceFolders,
449+
}
450+
}
451+
291452
module.exports = setupMockNpm
292453
module.exports.load = setupMockNpm
293454
module.exports.setGlobalNodeModules = setGlobalNodeModules
455+
module.exports.loadNpmWithRegistry = loadNpmWithRegistry
456+
module.exports.workspaceMock = workspaceMock

0 commit comments

Comments
 (0)