Skip to content

Commit 2a26e5e

Browse files
committed
fix: remove dedupe --save
* Removed dedupe --save documentation and attempted implementation. * Remove some unneeded otplease mocks from test `npm dedupe --save` didn't work in a easy to understand way. It would only update a top level dependency that was duplicated in the tree. Found this out rewriting the dedupe tests to be real. This is not very intuitive and it's best if folks use update or install for saving to package.json.
1 parent 31a0218 commit 2a26e5e

File tree

10 files changed

+357
-197
lines changed

10 files changed

+357
-197
lines changed

docs/content/commands/npm-dedupe.md

+3-21
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,9 @@ result in new modules being installed.
8080

8181
Using `npm find-dupes` will run the command in `--dry-run` mode.
8282

83-
Note that by default `npm dedupe` will not update the semver values of direct
84-
dependencies in your project `package.json`, if you want to also update
85-
values in `package.json` you can run: `npm dedupe --save` (or add the
86-
`save=true` option to a [configuration file](/configuring-npm/npmrc)
87-
to make that the default behavior).
83+
Note: `npm dedupe` will never update the semver values of direct
84+
dependencies in your project `package.json`, if you want to update
85+
values in `package.json` you can run: `npm update --save` instead.
8886

8987
### Configuration
9088

@@ -158,22 +156,6 @@ This configuration does not affect `npm ci`.
158156
<!-- automatically generated, do not edit manually -->
159157
<!-- see lib/utils/config/definitions.js -->
160158
161-
#### `save`
162-
163-
* Default: `true` unless when using `npm update` or `npm dedupe` where it
164-
defaults to `false`
165-
* Type: Boolean
166-
167-
Save installed packages to a `package.json` file as dependencies.
168-
169-
When used with the `npm rm` command, removes the dependency from
170-
`package.json`.
171-
172-
Will also prevent writing to `package-lock.json` if set to `false`.
173-
174-
<!-- automatically generated, do not edit manually -->
175-
<!-- see lib/utils/config/definitions.js -->
176-
177159
#### `omit`
178160
179161
* Default: 'dev' if the `NODE_ENV` environment variable is set to

lib/commands/dedupe.js

+5-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ class Dedupe extends ArboristWorkspaceCmd {
1212
'legacy-bundling',
1313
'strict-peer-deps',
1414
'package-lock',
15-
'save',
1615
'omit',
1716
'ignore-scripts',
1817
'audit',
@@ -29,19 +28,17 @@ class Dedupe extends ArboristWorkspaceCmd {
2928
throw er
3029
}
3130

32-
// In the context of `npm dedupe` the save
33-
// config value should default to `false`
34-
const save = this.npm.config.isDefault('save')
35-
? false
36-
: this.npm.config.get('save')
37-
3831
const dryRun = this.npm.config.get('dry-run')
3932
const where = this.npm.prefix
4033
const opts = {
4134
...this.npm.flatOptions,
4235
path: where,
4336
dryRun,
44-
save,
37+
// Saving during dedupe would only update if one of your direct
38+
// dependencies was also duplicated somewhere in your tree. It would be
39+
// confusing if running this were to also update your package.json. In
40+
// order to reduce potential confusion we set this to false.
41+
save: false,
4542
workspaces: this.workspaceNames,
4643
}
4744
const arb = new Arborist(opts)

lib/npm.js

+3
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,16 @@ class Npm extends EventEmitter {
131131

132132
const isGlobal = this.config.get('global')
133133
const workspacesEnabled = this.config.get('workspaces')
134+
// if cwd is a workspace, the default is set to [that workspace]
134135
const implicitWorkspace = this.config.get('workspace', 'default').length > 0
135136
const workspacesFilters = this.config.get('workspace')
136137
const includeWorkspaceRoot = this.config.get('include-workspace-root')
137138
// only call execWorkspaces when we have workspaces explicitly set
138139
// or when it is implicit and not in our ignore list
139140
const hasWorkspaceFilters = workspacesFilters.length > 0
140141
const invalidWorkspaceConfig = workspacesEnabled === false && hasWorkspaceFilters
142+
143+
// (-ws || -w foo) && (cwd is not a workspace || command is not ignoring implicit workspaces)
141144
const filterByWorkspaces = (workspacesEnabled || hasWorkspaceFilters) &&
142145
(!implicitWorkspace || !command.ignoreImplicitWorkspace)
143146
// normally this would go in the constructor, but our tests don't

tap-snapshots/test/lib/load-all-commands.js.test.cjs

-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ npm dedupe
161161
162162
Options:
163163
[--global-style] [--legacy-bundling] [--strict-peer-deps] [--no-package-lock]
164-
[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]
165164
[--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]] [--ignore-scripts]
166165
[--no-audit] [--no-bin-links] [--no-fund] [--dry-run]
167166
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]

tap-snapshots/test/lib/utils/npm-usage.js.test.cjs

-1
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,6 @@ All commands:
293293
294294
Options:
295295
[--global-style] [--legacy-bundling] [--strict-peer-deps] [--no-package-lock]
296-
[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]
297296
[--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]] [--ignore-scripts]
298297
[--no-audit] [--no-bin-links] [--no-fund] [--dry-run]
299298
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]

test/fixtures/mock-npm.js

+4
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ const LoadMockNpm = async (t, {
8383

8484
const { Npm, ...rest } = RealMockNpm(t, mocks)
8585

86+
// We want to fail fast when writing tests. Default this to 0 unless it was
87+
// explicitly set in a test.
88+
config = { 'fetch-retries': 0, ...config }
89+
8690
if (!init && load) {
8791
throw new Error('cant `load` without `init`')
8892
}

test/fixtures/mock-registry.js

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Mock registry class
3+
*
4+
* This should end up as the centralized place where we generate test fixtures
5+
* for tests against any registry data.
6+
*/
7+
const pacote = require('pacote')
8+
class MockRegistry {
9+
#tap
10+
#nock
11+
#registry
12+
#authorization
13+
14+
constructor (opts) {
15+
if (!opts.registry) {
16+
throw new Error('mock registry requires a registry value')
17+
}
18+
this.#registry = (new URL(opts.registry)).origin
19+
this.#authorization = opts.authorization
20+
// Required for this.package
21+
this.#tap = opts.tap
22+
}
23+
24+
get nock () {
25+
if (!this.#nock) {
26+
const tnock = require('./tnock.js')
27+
const reqheaders = {}
28+
if (this.#authorization) {
29+
reqheaders.authorization = `Bearer ${this.#authorization}`
30+
}
31+
this.#nock = tnock(this.#tap, this.#registry, { reqheaders })
32+
}
33+
return this.#nock
34+
}
35+
36+
set nock (nock) {
37+
this.#nock = nock
38+
}
39+
40+
async package ({ manifest, times = 1, query, tarballs }) {
41+
let nock = this.nock
42+
if (!nock) {
43+
throw new Error('cannot mock packages without a tap fixture')
44+
}
45+
nock = nock.get(`/${manifest.name}`).times(times)
46+
if (query) {
47+
nock = nock.query(query)
48+
}
49+
nock = nock.reply(200, manifest)
50+
if (tarballs) {
51+
for (const version in manifest.versions) {
52+
const packument = manifest.versions[version]
53+
const dist = new URL(packument.dist.tarball)
54+
const tarball = await pacote.tarball(tarballs[version])
55+
nock.get(dist.pathname).reply(200, tarball)
56+
}
57+
}
58+
this.nock = nock
59+
}
60+
61+
// the last packument in the packuments array will be tagged as latest
62+
manifest ({ name = 'test-package', packuments } = {}) {
63+
packuments = this.packuments(packuments, name)
64+
const latest = packuments.slice(-1)[0]
65+
const manifest = {
66+
_id: `${name}@${latest.version}`,
67+
_rev: '00-testdeadbeef',
68+
name,
69+
description: 'test package mock manifest',
70+
dependencies: {},
71+
versions: {},
72+
time: {},
73+
'dist-tags': { latest: latest.version },
74+
...latest,
75+
}
76+
77+
for (const packument of packuments) {
78+
manifest.versions[packument.version] = {
79+
_id: `${name}@${packument.version}`,
80+
name,
81+
description: 'test package mock manifest',
82+
dependencies: {},
83+
dist: {
84+
tarball: `${this.#registry}/${name}/-/${name}-${packument.version}.tgz`,
85+
},
86+
...packument,
87+
}
88+
manifest.time[packument.version] = new Date()
89+
}
90+
91+
return manifest
92+
}
93+
94+
packuments (packuments = ['1.0.0'], name) {
95+
return packuments.map(p => this.packument(p, name))
96+
}
97+
98+
// Generate packument from shorthand
99+
packument (packument, name = 'test-package') {
100+
if (!packument.version) {
101+
packument = { version: packument }
102+
}
103+
return {
104+
name,
105+
version: '1.0.0',
106+
description: 'mocked test package',
107+
dependencies: {},
108+
...packument,
109+
}
110+
}
111+
}
112+
113+
module.exports = MockRegistry

test/lib/commands/dedupe.js

+70-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
const t = require('tap')
22
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
3+
const path = require('path')
4+
const fs = require('fs')
5+
6+
const MockRegistry = require('../../fixtures/mock-registry.js')
37

48
t.test('should throw in global mode', async (t) => {
59
const { npm } = await loadMockNpm(t, {
@@ -14,45 +18,78 @@ t.test('should throw in global mode', async (t) => {
1418
)
1519
})
1620

17-
t.test('should remove dupes using Arborist', async (t) => {
18-
t.plan(5)
19-
const { npm } = await loadMockNpm(t, {
20-
mocks: {
21-
'@npmcli/arborist': function (args) {
22-
t.ok(args, 'gets options object')
23-
t.ok(args.path, 'gets path option')
24-
t.ok(args.dryRun, 'gets dryRun from user')
25-
this.dedupe = () => {
26-
t.ok(true, 'dedupe is called')
27-
}
28-
},
29-
'../../lib/utils/reify-finish.js': (npm, arb) => {
30-
t.ok(arb, 'gets arborist tree')
21+
const testTop = {
22+
name: 'test-top',
23+
version: '1.0.0',
24+
dependencies: {
25+
'test-dep-a': '*',
26+
'test-dep-b': '*',
27+
},
28+
}
29+
const testDepA = {
30+
name: 'test-dep-a',
31+
version: '1.0.1',
32+
dependencies: { 'test-sub': '*' },
33+
}
34+
const testDepB = {
35+
name: 'test-dep-b',
36+
version: '1.0.0',
37+
dependencies: { 'test-sub': '*' },
38+
}
39+
const testSub = {
40+
name: 'test-sub',
41+
version: '1.0.0',
42+
}
43+
44+
const treeWithDupes = {
45+
'package.json': JSON.stringify(testTop),
46+
node_modules: {
47+
'test-dep-a': {
48+
'package.json': JSON.stringify(testDepA),
49+
node_modules: {
50+
'test-sub': {
51+
'package.json': JSON.stringify(testSub),
52+
},
3153
},
3254
},
33-
config: {
34-
'dry-run': 'true',
55+
'test-dep-b': {
56+
'package.json': JSON.stringify(testDepB),
57+
node_modules: {
58+
'test-sub': {
59+
'package.json': JSON.stringify(testSub),
60+
},
61+
},
3562
},
63+
},
64+
}
65+
66+
t.test('dedupe', async (t) => {
67+
const { npm, joinedOutput } = await loadMockNpm(t, {
68+
prefixDir: treeWithDupes,
69+
})
70+
const registry = new MockRegistry({
71+
tap: t,
72+
registry: npm.config.get('registry'),
73+
})
74+
const manifestSub = registry.manifest({
75+
name: 'test-sub',
76+
packuments: [{ version: '1.0.0' }],
3677
})
37-
await npm.exec('dedupe', [])
38-
})
3978

40-
t.test('should remove dupes using Arborist - no arguments', async (t) => {
41-
t.plan(2)
42-
const { npm } = await loadMockNpm(t, {
43-
mocks: {
44-
'@npmcli/arborist': function (args) {
45-
t.ok(args.dryRun, 'gets dryRun from config')
46-
t.ok(args.save, 'gets user-set save value from config')
47-
this.dedupe = () => {}
48-
},
49-
'../../lib/utils/reify-output.js': () => {},
50-
'../../lib/utils/reify-finish.js': () => {},
51-
},
52-
config: {
53-
'dry-run': true,
54-
save: true,
79+
await registry.package({
80+
manifest: manifestSub,
81+
tarballs: {
82+
'1.0.0': path.join(npm.prefix, 'node_modules', 'test-dep-a', 'node_modules', 'test-sub'),
5583
},
5684
})
5785
await npm.exec('dedupe', [])
86+
t.match(joinedOutput(), /added 1 package, and removed 2 packages/)
87+
t.ok(fs.existsSync(path.join(npm.prefix, 'node_modules', 'test-sub')), 'test-sub was hoisted')
88+
t.notOk(
89+
fs.existsSync(path.join(npm.prefix, 'node_modules', 'test-dep-a', 'node_modules', 'test-sub')),
90+
'test-dep-a/test-sub was removed'
91+
)
92+
t.notOk(
93+
fs.existsSync(path.join(npm.prefix, 'node_modules', 'test-dep-b', 'node_modules', 'test-sub')),
94+
'test-dep-b/test-sub was removed')
5895
})

0 commit comments

Comments
 (0)