Files
pnpm/pkg-manager/core/test/install/autoInstallPeers.ts
Zoltan Kochan 54c4fc4fb4 fix: respect peer dep range in hoistPeers when preferred versions exist (#10655)
* fix: respect peer dep range in hoistPeers when preferred versions exist

Previously, hoistPeers used semver.maxSatisfying(versions, '*') which
picked the highest preferred version from the lockfile regardless of the
peer dep range. This caused overrides that narrow a peer dep range to be
ignored when a stale version existed in the lockfile.

Now hoistPeers first tries semver.maxSatisfying(versions, range) to find
a preferred version that satisfies the actual peer dep range. If none
satisfies it and autoInstallPeers is enabled, it falls back to the range
itself so pnpm resolves a matching version from the registry.

* fix: only fall back to exact-version range for overrides, handle workspace: protocol

- When no preferred version satisfies the peer dep range, only use the
  range directly if it is an exact version (e.g. "4.3.0" from an override).
  For semver ranges (e.g. "1", "^2.0.0"), fall back to the old behavior
  of picking the highest preferred version for deduplication.
- Guard against workspace: protocol ranges that would cause
  semver.maxSatisfying to throw.
- Add unit tests for hoisting deduplication and workspace: ranges.

* fix: only apply range-constrained peer selection for exact versions

The previous approach used semver.maxSatisfying(versions, range) for all
peer dep ranges, which broke aliased-dependency deduplication — e.g. when
three aliases of @pnpm.e2e/peer-c existed at 1.0.0, 1.0.1, and 2.0.0,
range ^1.0.0 would pick 1.0.1 instead of 2.0.0.

Now the range-aware logic only activates when the range is an exact
version (semver.valid), which is the override case (e.g. "4.3.0").
Regular semver ranges fall back to picking the highest preferred version.
2026-02-22 22:04:35 +01:00

708 lines
25 KiB
TypeScript

import path from 'path'
import { assertProject } from '@pnpm/assert-project'
import { addDependenciesToPackage, install, mutateModules, mutateModulesInSingleProject, type PackageManifest } from '@pnpm/core'
import { prepareEmpty, preparePackages } from '@pnpm/prepare'
import { addDistTag, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { type ProjectRootDir } from '@pnpm/types'
import { sync as rimraf } from '@zkochan/rimraf'
import { createPeerDepGraphHash } from '@pnpm/dependency-path'
import { testDefaults } from '../utils/index.js'
test('auto install non-optional peer dependencies', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-a', version: '1.0.0', distTag: 'latest' })
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/abc-optional-peers@1.0.0'], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots)).toStrictEqual([
'@pnpm.e2e/abc-optional-peers@1.0.0(@pnpm.e2e/peer-a@1.0.0)',
'@pnpm.e2e/peer-a@1.0.0',
])
project.hasNot('@pnpm.e2e/peer-a')
})
test('auto install the common peer dependency', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-c', version: '1.0.1', distTag: 'latest' })
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/wants-peer-c-1', '@pnpm.e2e/wants-peer-c-1.0.0'], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm.e2e/peer-c@1.0.0',
'@pnpm.e2e/wants-peer-c-1.0.0@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/wants-peer-c-1@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
].sort())
})
test('do not auto install when there is no common peer dependency range intersection', async () => {
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/wants-peer-c-1', '@pnpm.e2e/wants-peer-c-2'], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots)).toStrictEqual([
'@pnpm.e2e/wants-peer-c-1@1.0.0',
'@pnpm.e2e/wants-peer-c-2@1.0.0',
])
})
test('auto install latest when there is no common peer dependency range intersection', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-c', version: '2.0.0', distTag: 'latest' })
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/wants-peer-c-1', '@pnpm.e2e/wants-peer-c-2'], testDefaults({
autoInstallPeers: true,
autoInstallPeersFromHighestMatch: true,
}))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm.e2e/peer-c@2.0.0',
'@pnpm.e2e/wants-peer-c-1@1.0.0(@pnpm.e2e/peer-c@2.0.0)',
'@pnpm.e2e/wants-peer-c-2@1.0.0(@pnpm.e2e/peer-c@2.0.0)',
].sort())
})
test('don\'t fail on linked package, when peers are auto installed', async () => {
const pkgManifest = {
dependencies: {
linked: 'link:../linked',
},
}
preparePackages([
{
location: 'linked',
package: {
name: 'linked',
peerDependencies: {
'peer-c': '1.0.0',
},
},
},
{
location: 'pkg',
package: pkgManifest,
},
])
process.chdir('pkg')
const { updatedManifest } = await addDependenciesToPackage(pkgManifest, ['@pnpm.e2e/peer-b'], testDefaults({ autoInstallPeers: true }))
expect(Object.keys(updatedManifest.dependencies ?? {})).toStrictEqual(['linked', '@pnpm.e2e/peer-b'])
})
test('hoist a peer dependency in order to reuse it by other dependencies, when it satisfies them', async () => {
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm/xyz-parent-parent-parent-parent', '@pnpm/xyz-parent-parent-with-xyz'], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
const suffix = createPeerDepGraphHash([{ name: '@pnpm/x', version: '1.0.0' }, { name: '@pnpm/y', version: '1.0.0' }, { name: '@pnpm/z', version: '1.0.0' }])
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm/x@1.0.0',
'@pnpm/xyz-parent-parent-with-xyz@1.0.0',
'@pnpm/y@1.0.0',
'@pnpm/z@1.0.0',
`@pnpm/xyz-parent-parent-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent@1.0.0${suffix}`,
`@pnpm/xyz@1.0.0${suffix}`,
].sort())
})
test('don\'t hoist a peer dependency when there is a root dependency by that name', async () => {
const project = prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm/xyz-parent-parent-parent-parent',
'@pnpm/xyz-parent-parent-with-xyz',
'@pnpm/x@npm:@pnpm.e2e/peer-a@1.0.0',
`http://localhost:${REGISTRY_MOCK_PORT}/@pnpm/y/-/y-2.0.0.tgz`,
], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
const suffix1 = createPeerDepGraphHash([{ name: '@pnpm/y', version: '2.0.0' }, { name: '@pnpm/z', version: '1.0.0' }, { name: '@pnpm.e2e/peer-a', version: '1.0.0' }])
const suffix2 = createPeerDepGraphHash([{ name: '@pnpm/x', version: '1.0.0' }, { name: '@pnpm/y', version: '1.0.0' }, { name: '@pnpm/z', version: '1.0.0' }])
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm.e2e/peer-a@1.0.0',
'@pnpm/x@1.0.0',
`@pnpm/xyz-parent-parent-parent-parent@1.0.0${suffix1}`,
`@pnpm/xyz-parent-parent-parent@1.0.0${suffix1}`,
'@pnpm/xyz-parent-parent-with-xyz@1.0.0',
`@pnpm/xyz-parent-parent@1.0.0${suffix1}`,
`@pnpm/xyz-parent@1.0.0${suffix1}`,
`@pnpm/xyz-parent@1.0.0${suffix2}`,
`@pnpm/xyz@1.0.0${suffix1}`,
`@pnpm/xyz@1.0.0${suffix2}`,
'@pnpm/y@1.0.0',
'@pnpm/y@2.0.0',
'@pnpm/z@1.0.0',
].sort())
})
test('don\'t auto-install a peer dependency, when that dependency is in the root', async () => {
const project = prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm/xyz-parent-parent-parent-parent',
'@pnpm/x@npm:@pnpm.e2e/peer-a@1.0.0',
`http://localhost:${REGISTRY_MOCK_PORT}/@pnpm/y/-/y-2.0.0.tgz`,
], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
const suffix = createPeerDepGraphHash([{ name: '@pnpm/y', version: '2.0.0' }, { name: '@pnpm/z', version: '1.0.0' }, { name: '@pnpm.e2e/peer-a', version: '1.0.0' }])
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
`@pnpm/xyz-parent-parent-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent-parent@1.0.0${suffix}`,
`@pnpm/xyz-parent@1.0.0${suffix}`,
`@pnpm/xyz@1.0.0${suffix}`,
'@pnpm/y@2.0.0',
'@pnpm/z@1.0.0',
'@pnpm.e2e/peer-a@1.0.0',
].sort())
})
test('don\'t install the same missing peer dependency twice', async () => {
await addDistTag({ package: '@pnpm/y', version: '2.0.0', distTag: 'latest' })
const project = prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm.e2e/has-has-y-peer-peer',
], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm/y@1.0.0',
'@pnpm.e2e/has-has-y-peer-peer@1.0.0(@pnpm.e2e/has-y-peer@1.0.0(@pnpm/y@1.0.0))(@pnpm/y@1.0.0)',
'@pnpm.e2e/has-y-peer@1.0.0(@pnpm/y@1.0.0)',
].sort())
})
// Covers https://github.com/pnpm/pnpm/issues/5373
test('prefer the peer dependency version already used in the root', async () => {
await addDistTag({ package: '@pnpm/y', version: '2.0.0', distTag: 'latest' })
const project = prepareEmpty()
await install({
peerDependencies: {
'@pnpm.e2e/has-y-peer': '1.0.0',
'@pnpm/y': '^1.0.0',
},
}, testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm/y@1.0.0',
'@pnpm.e2e/has-y-peer@1.0.0(@pnpm/y@1.0.0)',
].sort())
})
test('automatically install root peer dependencies', async () => {
const project = prepareEmpty()
let { updatedManifest: manifest } = await install({
dependencies: {
'is-negative': '^1.0.1',
},
peerDependencies: {
'is-positive': '^1.0.0',
},
}, testDefaults({ autoInstallPeers: true, resolutionMode: 'lowest-direct' }))
project.has('is-positive')
project.has('is-negative')
{
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies).toStrictEqual({
'is-positive': {
specifier: '^1.0.0',
version: '1.0.0',
},
'is-negative': {
specifier: '^1.0.1',
version: '1.0.1',
},
})
}
// Automatically install the peer dependency when the lockfile is up to date
rimraf('node_modules')
await install(manifest, testDefaults({ autoInstallPeers: true, frozenLockfile: true }))
project.has('is-positive')
project.has('is-negative')
// The auto installed peer is not removed when a new dependency is added
manifest = (await addDependenciesToPackage(manifest, ['is-odd@1.0.0'], testDefaults({ autoInstallPeers: true, resolutionMode: 'lowest-direct' }))).updatedManifest
project.has('is-odd')
project.has('is-positive')
project.has('is-negative')
{
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies).toStrictEqual({
'is-odd': {
specifier: '1.0.0',
version: '1.0.0',
},
'is-positive': {
specifier: '^1.0.0',
version: '1.0.0',
},
'is-negative': {
specifier: '^1.0.1',
version: '1.0.1',
},
})
}
// The auto installed peer is not removed when a dependency is removed
await mutateModulesInSingleProject({
dependencyNames: ['is-odd'],
manifest,
mutation: 'uninstallSome',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ autoInstallPeers: true, resolutionMode: 'lowest-direct' }))
project.hasNot('is-odd')
project.has('is-positive')
project.has('is-negative')
{
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies).toStrictEqual({
'is-positive': {
specifier: '^1.0.0',
version: '1.0.0',
},
'is-negative': {
specifier: '^1.0.1',
version: '1.0.1',
},
})
}
})
test('automatically install peer dependency when it is a dev dependency in another workspace project', async () => {
prepareEmpty()
await mutateModules([
{
mutation: 'install',
rootDir: path.resolve('project-1') as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('project-2') as ProjectRootDir,
},
], testDefaults({
allProjects: [
{
buildIndex: 0,
manifest: {
name: 'project-1',
devDependencies: {
'is-positive': '1.0.0',
},
},
rootDir: path.resolve('project-1') as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'project-2',
peerDependencies: {
'is-positive': '1.0.0',
},
},
rootDir: path.resolve('project-2') as ProjectRootDir,
},
],
autoInstallPeers: true,
}))
const project = assertProject(process.cwd())
const lockfile = project.readLockfile()
expect(lockfile.importers['project-1'].devDependencies).toStrictEqual({
'is-positive': {
specifier: '1.0.0',
version: '1.0.0',
},
})
expect(lockfile.importers['project-2'].dependencies).toStrictEqual({
'is-positive': {
specifier: '1.0.0',
version: '1.0.0',
},
})
})
// Covers https://github.com/pnpm/pnpm/issues/4820
test('auto install peer deps in a workspace. test #1', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-c', version: '1.0.0', distTag: 'latest' })
const project = prepareEmpty()
await mutateModules([
{
mutation: 'install',
rootDir: path.resolve('project1') as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('project2') as ProjectRootDir,
},
], testDefaults({
autoInstallPeers: true,
allProjects: [
{
buildIndex: 0,
manifest: {
name: 'root-project',
devDependencies: {
'@pnpm.e2e/abc-parent-with-ab': '1.0.0',
},
},
rootDir: path.resolve('project1') as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'project',
peerDependencies: {
'@pnpm.e2e/abc-parent-with-ab': '1.0.0',
},
},
rootDir: path.resolve('project2') as ProjectRootDir,
},
],
dedupePeerDependents: false,
}))
const lockfile = project.readLockfile()
expect(lockfile.importers['project1'].devDependencies?.['@pnpm.e2e/abc-parent-with-ab']?.version).toBe('1.0.0(@pnpm.e2e/peer-c@1.0.0)')
expect(lockfile.importers['project2'].dependencies?.['@pnpm.e2e/abc-parent-with-ab']?.version).toBe('1.0.0(@pnpm.e2e/peer-c@1.0.0)')
})
test('auto install peer deps in a workspace. test #2', async () => {
prepareEmpty()
await mutateModules([
{
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('project') as ProjectRootDir,
},
], testDefaults({
autoInstallPeers: true,
allProjects: [
{
buildIndex: 0,
manifest: {
name: 'root-project',
devDependencies: {
'@pnpm.e2e/abc-parent-with-ab': '1.0.0',
'@pnpm.e2e/peer-c': '1.0.0',
},
},
rootDir: process.cwd() as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'project',
peerDependencies: {
'@pnpm.e2e/abc-parent-with-ab': '1.0.0',
},
},
rootDir: path.resolve('project') as ProjectRootDir,
},
],
}))
})
// This test may be removed if autoInstallPeers will become true by default
test('installation on a package with many complex circular dependencies does not fail when auto install peers is on', async () => {
prepareEmpty()
await addDependenciesToPackage({}, ['webpack@4.46.0'], testDefaults({ autoInstallPeers: true }))
})
// This test may be removed if autoInstallPeers will become true by default
test('installation on a workspace with many complex circular dependencies does not fail when auto install peers is on', async () => {
prepareEmpty()
await mutateModules([
{
mutation: 'install',
rootDir: path.resolve('project1') as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('project2') as ProjectRootDir,
},
], testDefaults({
autoInstallPeers: true,
ignoreScripts: true,
lockfileOnly: true,
strictPeerDependencies: false,
registries: {
// A temporary workaround due to stylus removal from the npm registry.
// Related issue: https://github.com/stylus/stylus/issues/2938
default: 'https://registry.npmmirror.com',
},
allProjects: [
{
buildIndex: 0,
manifest: {
name: 'project1',
dependencies: {
'@angular/common': '14.2.4',
'@angular/core': '14.2.4',
'@angular/forms': '14.2.4',
'@angular/platform-browser': '14.2.4',
'@angular/platform-browser-dynamic': '14.2.4',
'@angular/router': '14.2.4',
'@capacitor/app': '4.0.1',
'@capacitor/core': '4.3.0',
'@capacitor/haptics': '4.0.1',
'@capacitor/keyboard': '4.0.1',
'@capacitor/status-bar': '4.0.1',
'@ionic/angular': '6.2.9',
'@ionic/core': '6.2.9',
ionicons: '6.0.3',
'ng-particles': '3.3.3',
rxjs: '7.5.7',
tslib: '2.4.0',
tsparticles: '2.3.4',
'tsparticles-engine': '2.3.3',
'tsparticles-interaction-external-attract': '2.3.3',
'tsparticles-interaction-external-bounce': '2.3.3',
'tsparticles-interaction-external-bubble': '2.3.3',
'tsparticles-interaction-external-connect': '2.3.3',
'tsparticles-interaction-external-grab': '2.3.3',
'tsparticles-interaction-external-pause': '2.3.3',
'tsparticles-interaction-external-push': '2.3.3',
'tsparticles-interaction-external-remove': '2.3.3',
'tsparticles-interaction-external-repulse': '2.3.4',
'tsparticles-interaction-external-slow': '2.3.3',
'tsparticles-interaction-external-trail': '2.3.3',
'tsparticles-interaction-particles-attract': '2.3.3',
'tsparticles-interaction-particles-collisions': '2.3.3',
'tsparticles-interaction-particles-links': '2.3.3',
'tsparticles-move-base': '2.3.3',
'tsparticles-move-parallax': '2.3.3',
'tsparticles-particles.js': '2.3.3',
'tsparticles-plugin-absorbers': '2.3.4',
'tsparticles-plugin-emitters': '2.3.4',
'tsparticles-plugin-polygon-mask': '2.3.3',
'tsparticles-shape-circle': '2.3.3',
'tsparticles-shape-image': '2.3.3',
'tsparticles-shape-line': '2.3.3',
'tsparticles-shape-polygon': '2.3.3',
'tsparticles-shape-square': '2.3.3',
'tsparticles-shape-star': '2.3.3',
'tsparticles-shape-text': '2.3.3',
'tsparticles-slim': '2.3.4',
'tsparticles-updater-angle': '2.3.3',
'tsparticles-updater-color': '2.3.3',
'tsparticles-updater-destroy': '2.3.3',
'tsparticles-updater-life': '2.3.3',
'tsparticles-updater-opacity': '2.3.3',
'tsparticles-updater-out-modes': '2.3.3',
'tsparticles-updater-roll': '2.3.3',
'tsparticles-updater-size': '2.3.3',
'tsparticles-updater-stroke-color': '2.3.3',
'tsparticles-updater-tilt': '2.3.3',
'tsparticles-updater-twinkle': '2.3.3',
'tsparticles-updater-wobble': '2.3.3',
'zone.js': '0.11.8',
},
devDependencies: {
'@angular-devkit/build-angular': '14.2.4',
'@angular-eslint/builder': '14.1.2',
'@angular-eslint/eslint-plugin': '14.1.2',
'@angular-eslint/eslint-plugin-template': '14.1.2',
'@angular-eslint/template-parser': '14.1.2',
'@angular/cli': '14.2.4',
'@angular/compiler': '14.2.4',
'@angular/compiler-cli': '14.2.4',
'@angular/language-service': '14.2.4',
'@capacitor/cli': '4.3.0',
'@ionic/angular-toolkit': '7.0.0',
'@types/jasmine': '4.3.0',
'@types/jasminewd2': '2.0.10',
'@types/node': '18.7.23',
'@typescript-eslint/eslint-plugin': '5.38.1',
'@typescript-eslint/parser': '5.38.1',
eslint: '8.24.0',
'eslint-plugin-import': '2.26.0',
'eslint-plugin-jsdoc': '39.3.6',
'eslint-plugin-prefer-arrow': '1.2.3',
'jasmine-core': '4.4.0',
'jasmine-spec-reporter': '7.0.0',
karma: '6.4.1',
'karma-chrome-launcher': '3.1.1',
'karma-coverage': '2.2.0',
'karma-coverage-istanbul-reporter': '3.0.3',
'karma-jasmine': '5.1.0',
'karma-jasmine-html-reporter': '2.0.0',
protractor: '7.0.0',
'ts-node': '10.9.1',
typescript: '4.8.4',
},
},
rootDir: path.resolve('project1') as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'project2',
devDependencies: {
'@typescript-eslint/eslint-plugin': '5.38.1',
'@typescript-eslint/parser': '5.38.1',
copyfiles: '2.4.1',
enzyme: '3.11.0',
'enzyme-adapter-preact-pure': '4.0.1',
eslint: '8.24.0',
'eslint-config-preact': '1.3.0',
'eslint-config-prettier': '8.5.0',
'eslint-plugin-react-hooks': '4.6.0',
'identity-obj-proxy': '3.0.0',
'preact-cli': '3.4.1',
prettier: '2.7.1',
'sirv-cli': '2.0.2',
},
dependencies: {
preact: '10.11.0',
'preact-particles': '2.3.3',
'preact-render-to-string': '5.2.4',
'preact-router': '4.1.0',
tsparticles: '2.3.4',
'tsparticles-engine': '2.3.3',
},
},
rootDir: path.resolve('project2') as ProjectRootDir,
},
],
}))
})
test('do not override the direct dependency with an auto installed peer dependency', async () => {
const includedDeps = new Set([
'@angular-devkit/build-angular',
'@angular/platform-browser-dynamic',
'inquirer',
'rxjs',
'@angular/common',
'rxjs',
])
const project = prepareEmpty()
await install({
dependencies: {
rxjs: '6.6.7',
},
devDependencies: {
'jest-preset-angular': '12.0.1',
},
}, testDefaults({
autoInstallPeers: true,
hooks: {
// This hook may be removed and the test will still be valid.
// The only reason the hook was added to remove the packages that aren't needed for the tests and make the test faster.
readPackage: [
(pkg: PackageManifest) => {
for (const depType of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta'] as const) {
if (pkg[depType]) {
for (const depName of Object.keys(pkg[depType] ?? {})) {
if (!includedDeps.has(depName)) {
delete pkg[depType]![depName]
}
}
}
}
if (pkg.name === '@angular-devkit/build-angular' && pkg.dependencies) {
delete pkg.dependencies.rxjs
}
return pkg
},
],
},
}))
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies?.rxjs.version).toBe('6.6.7')
})
test('auto install hoisted peer dependency', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-c', version: '1.0.0', distTag: 'latest' })
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/has-peer-c-in-deps@1.0.0', '@pnpm.e2e/abc'], testDefaults({ autoInstallPeers: true }))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots).filter((depPath) => depPath.startsWith('@pnpm.e2e/peer-c@'))).toStrictEqual([
'@pnpm.e2e/peer-c@2.0.0',
])
})
test('auto install peer of optional peer', async () => {
const project = prepareEmpty()
await mutateModules([
{
mutation: 'install',
rootDir: path.resolve('project-1') as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('project-2') as ProjectRootDir,
},
], testDefaults({
allProjects: [
{
buildIndex: 0,
manifest: {
name: 'project-1',
dependencies: {
'@pnpm.e2e/has-optional-peer-with-peer': '1.0.0',
},
},
rootDir: path.resolve('project-1') as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'project-2',
dependencies: {
'@pnpm.e2e/has-y-peer': '1.0.0',
},
},
rootDir: path.resolve('project-2') as ProjectRootDir,
},
],
autoInstallPeers: true,
}))
const lockfile = project.readLockfile()
expect(Object.keys(lockfile.snapshots)).toStrictEqual([
'@pnpm.e2e/has-optional-peer-with-peer@1.0.0(@pnpm.e2e/has-y-peer@1.0.0(@pnpm/y@2.0.0))',
'@pnpm.e2e/has-y-peer@1.0.0(@pnpm/y@2.0.0)',
'@pnpm/y@2.0.0',
])
project.hasNot('@pnpm.e2e/peer-a')
})
test('override narrows auto-installed peer dep range on subsequent install', async () => {
// Scenario: a lockfile has peer-c@1.0.1 from a previous install.
// Then the user adds an override to pin peer-c to 1.0.0.
// The override should be respected, not the stale lockfile version.
await addDistTag({ package: '@pnpm.e2e/peer-c', version: '1.0.1', distTag: 'latest' })
const project = prepareEmpty()
// Step 1: install without override — auto-installs peer-c@1.0.1
const manifest = {
dependencies: {
'@pnpm.e2e/wants-peer-c-1': '1.0.0',
},
}
await install(manifest, testDefaults({ autoInstallPeers: true }))
{
const lockfile = project.readLockfile()
expect(lockfile.packages).toHaveProperty(['@pnpm.e2e/peer-c@1.0.1'])
}
// Step 2: reinstall with override narrowing peer-c to 1.0.0
const overrides = { '@pnpm.e2e/peer-c': '1.0.0' }
await mutateModulesInSingleProject({
manifest,
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ autoInstallPeers: true, overrides }))
{
const lockfile = project.readLockfile()
// peer-c should now be 1.0.0, not the stale 1.0.1 from the previous lockfile
expect(lockfile.packages).toHaveProperty(['@pnpm.e2e/peer-c@1.0.0'])
expect(lockfile.packages).not.toHaveProperty(['@pnpm.e2e/peer-c@1.0.1'])
}
})