mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-24 08:35:19 -04:00
* feat: add `dedupePeers` option to reduce peer dependency duplication When enabled, this option applies two optimizations to peer dependency resolution: 1. Version-only peer suffixes: Uses name@version instead of full dep paths (including nested peer suffixes) when building peer identity hashes. This eliminates deeply nested suffixes like (foo@1.0.0(bar@2.0.0)). 2. Transitive peer pruning: Only directly declared peer dependencies are included in a package's suffix. Transitive peers from children are not propagated upward, preventing combinatorial explosion while maintaining correct node_modules layout. The option is scoped per-project: each workspace project defines a peer resolution environment, and all packages within that project's tree share that environment. Projects with different peer versions correctly produce different instances. Closes #11070 * fix: pass dedupePeers to getOutdatedLockfileSetting and use spread for lockfile write The frozen install path (used by approve-builds) calls getOutdatedLockfileSetting but was missing the dedupePeers parameter. This caused a false LOCKFILE_CONFIG_MISMATCH error because the lockfile had the key written (as undefined/null via YAML serialization) while the check function received undefined for the config value. Fix: pass dedupePeers to the settings check call, and use spread syntax to only write the dedupePeers key to lockfile settings when it's truthy (avoiding undefined keys). * fix: write dedupePeers to lockfile like other settings Write the value directly instead of spread syntax, and use the same != null guard pattern as autoInstallPeers in the settings checker. * test: add integration test for dedupePeers in peerDependencies.ts * fix: only write dedupePeers to lockfile when enabled When dedupePeers is false (default), don't write it to lockfile settings. This avoids adding a new key to every lockfile. * test: simplify dedupePeers test assertions * test: check exact snapshot keys in dedupePeers integration test * test: add workspace test for dedupePeers with different peer versions * fix: keep transitive peers in suffix with version-only IDs Instead of pruning transitive peers entirely (which prevented per-project differentiation), keep them but use version-only identifiers. This way: - Packages like abc-grand-parent still get a peer suffix when different projects provide different peer versions (correct per-project isolation) - But the suffixes use name@version instead of full dep paths, eliminating the nested parentheses that cause combinatorial explosion * refactor: extract peerNodeIdToPeerId helper in resolvePeers * refactor: simplify peerNodeIdToPeerId return * fix: pin peer-a dist tag in dedupePeers tests for CI stability * fix: address review comments - Register dedupe-peers in config schema, types, and defaults so .npmrc/pnpm-workspace.yaml settings are parsed correctly - Use Boolean() comparison in settings checker so enabling dedupePeers on a pre-existing lockfile triggers re-resolution - Fix changeset text and test names: transitive peers are still propagated, just with version-only IDs (no nested dep paths)
928 lines
28 KiB
TypeScript
928 lines
28 KiB
TypeScript
/// <reference path="../../../__typings__/index.d.ts" />
|
|
import type {
|
|
PeerDependencyIssuesByProjects,
|
|
PkgIdWithPatchHash,
|
|
PkgResolutionId,
|
|
ProjectRootDir,
|
|
} from '@pnpm/types'
|
|
|
|
import type { NodeId } from '../lib/nextNodeId.js'
|
|
import type { DependenciesTreeNode, PeerDependencies } from '../lib/resolveDependencies.js'
|
|
import { type PartialResolvedPackage, resolvePeers } from '../lib/resolvePeers.js'
|
|
|
|
test('resolve peer dependencies of cyclic dependencies', async () => {
|
|
const fooPkg = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
qar: { version: '1.0.0' },
|
|
zoo: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barPkg = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
foo: { version: '1.0.0' },
|
|
zoo: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph } = await resolvePeers({
|
|
allPeerDepNames: new Set(['foo', 'bar', 'qar', 'zoo']),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>foo/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: '',
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>foo/1.0.0>' as NodeId, {
|
|
children: {
|
|
bar: '>foo/1.0.0>bar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>' as NodeId, {
|
|
children: {
|
|
qar: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 1,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>' as NodeId, {
|
|
children: {
|
|
zoo: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'qar',
|
|
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
foo: { version: '1.0.0' },
|
|
bar: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 2,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>' as NodeId, {
|
|
children: {
|
|
foo: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId,
|
|
bar: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>bar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'zoo',
|
|
pkgIdWithPatchHash: 'zoo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
qar: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 3,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 4,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 4,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
lockfileDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
expect(Object.keys(dependenciesGraph)).toStrictEqual([
|
|
'foo/1.0.0',
|
|
'bar/1.0.0(foo/1.0.0)',
|
|
'qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)',
|
|
'zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0))',
|
|
'foo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0))(zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)))',
|
|
'bar/1.0.0(foo/1.0.0)(zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)))',
|
|
])
|
|
})
|
|
|
|
test('when a package is referenced twice in the dependencies graph and one of the times it cannot resolve its peers, still try to resolve it in the other occurrence', async () => {
|
|
const fooPkg = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
qar: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barPkg = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const zooPkg = {
|
|
name: 'zoo',
|
|
pkgIdWithPatchHash: 'zoo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph } = await resolvePeers({
|
|
allPeerDepNames: new Set(['foo', 'bar', 'qar', 'zoo']),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['zoo', '>zoo/1.0.0>' as NodeId],
|
|
['bar', '>bar/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: '',
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>zoo/1.0.0>' as NodeId, {
|
|
children: {
|
|
foo: '>zoo/1.0.0>foo/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: zooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>zoo/1.0.0>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 1,
|
|
}],
|
|
['>bar/1.0.0>' as NodeId, {
|
|
children: {
|
|
zoo: '>bar/1.0.0>zoo/1.0.0>' as NodeId,
|
|
qar: '>bar/1.0.0>qar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 0,
|
|
}],
|
|
['>bar/1.0.0>zoo/1.0.0>' as NodeId, {
|
|
children: {
|
|
foo: '>bar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: zooPkg,
|
|
depth: 1,
|
|
}],
|
|
['>bar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 2,
|
|
}],
|
|
['>bar/1.0.0>qar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'qar',
|
|
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 1,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
|
|
'bar/1.0.0',
|
|
'foo/1.0.0',
|
|
'foo/1.0.0(qar/1.0.0)',
|
|
'qar/1.0.0',
|
|
'zoo/1.0.0',
|
|
'zoo/1.0.0(qar/1.0.0)',
|
|
])
|
|
})
|
|
|
|
describe('peer dependency issues', () => {
|
|
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
|
|
beforeAll(async () => {
|
|
const fooPkg = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
peer: { version: '1' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const fooWithOptionalPeer = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/2.0.0' as PkgIdWithPatchHash,
|
|
version: '2.0.0',
|
|
peerDependencies: {
|
|
peer: { version: '1', optional: true },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barPkg = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
peer: { version: '2' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barWithOptionalPeer = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/2.0.0' as PkgIdWithPatchHash,
|
|
version: '2.0.0',
|
|
peerDependencies: {
|
|
peer: { version: '2', optional: true },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const qarPkg = {
|
|
name: 'qar',
|
|
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
peer: { version: '^2.2.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
peerDependencyIssuesByProjects = (await resolvePeers({
|
|
allPeerDepNames: new Set(),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project1>foo/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project1' as PkgResolutionId,
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['bar', '>project2>bar/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project2' as PkgResolutionId,
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project3>foo/1.0.0>' as NodeId],
|
|
['bar', '>project3>bar/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project3' as PkgResolutionId,
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['bar', '>project4>bar/1.0.0>' as NodeId],
|
|
['qar', '>project4>qar/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project4' as PkgResolutionId,
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project5>foo/1.0.0>' as NodeId],
|
|
['bar', '>project5>bar/2.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project5' as PkgResolutionId,
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project6>foo/2.0.0>' as NodeId],
|
|
['bar', '>project6>bar/2.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project6' as PkgResolutionId,
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>project1>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project2>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project3>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project3>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project4>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project4>qar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: qarPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project5>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project5>bar/2.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barWithOptionalPeer,
|
|
depth: 0,
|
|
}],
|
|
['>project6>foo/2.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: fooWithOptionalPeer,
|
|
depth: 0,
|
|
}],
|
|
['>project6>bar/2.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barWithOptionalPeer,
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})).peerDependencyIssuesByProjects
|
|
})
|
|
it('should find peer dependency conflicts', () => {
|
|
expect(peerDependencyIssuesByProjects['project3'].conflicts).toStrictEqual(['peer'])
|
|
})
|
|
it('should find peer dependency conflicts when the peer is an optional peer of one of the dependencies', () => {
|
|
expect(peerDependencyIssuesByProjects['project5'].conflicts).toStrictEqual(['peer'])
|
|
})
|
|
it('should ignore conflicts between missing optional peer dependencies', () => {
|
|
expect(peerDependencyIssuesByProjects['project6'].conflicts).toStrictEqual([])
|
|
})
|
|
it('should pick the single wanted peer dependency range', () => {
|
|
expect(peerDependencyIssuesByProjects['project1'].intersections)
|
|
.toStrictEqual({ peer: '1' })
|
|
expect(peerDependencyIssuesByProjects['project2'].intersections)
|
|
.toStrictEqual({ peer: '2' })
|
|
})
|
|
it('should return the intersection of two compatible ranges', () => {
|
|
expect(peerDependencyIssuesByProjects['project4'].intersections)
|
|
.toStrictEqual({ peer: '>=2.2.0 <3.0.0-0' })
|
|
})
|
|
})
|
|
|
|
describe('unmet peer dependency issues', () => {
|
|
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
|
|
beforeAll(async () => {
|
|
peerDependencyIssuesByProjects = (await resolvePeers({
|
|
allPeerDepNames: new Set(),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project1>foo/1.0.0>' as NodeId],
|
|
['peer1', '>project1>peer1/1.0.0-rc.0>' as NodeId],
|
|
['peer2', '>project1>peer2/1.1.0-rc.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project1' as PkgResolutionId,
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>project1>foo/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'foo',
|
|
version: '1.0.0',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
peerDependencies: {
|
|
peer1: { version: '*' },
|
|
peer2: { version: '>=1' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 0,
|
|
}],
|
|
['>project1>peer1/1.0.0-rc.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'peer1',
|
|
version: '1.0.0-rc.0',
|
|
pkgIdWithPatchHash: 'peer/1.0.0-rc.0' as PkgIdWithPatchHash,
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 0,
|
|
}],
|
|
['>project1>peer2/1.1.0-rc.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'peer2',
|
|
version: '1.1.0-rc.0',
|
|
pkgIdWithPatchHash: 'peer/1.1.0-rc.0' as PkgIdWithPatchHash,
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})).peerDependencyIssuesByProjects
|
|
})
|
|
it('should not warn when the found package has prerelease version and the wanted range is *', () => {
|
|
expect(peerDependencyIssuesByProjects).not.toHaveProperty(['project1', 'bad', 'peer1'])
|
|
})
|
|
it('should not warn when the found package is a prerelease version but satisfies the range', () => {
|
|
expect(peerDependencyIssuesByProjects).not.toHaveProperty(['project1', 'bad', 'peer2'])
|
|
})
|
|
})
|
|
|
|
describe('unmet peer dependency issue resolved from subdependency', () => {
|
|
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
|
|
beforeAll(async () => {
|
|
peerDependencyIssuesByProjects = (await resolvePeers({
|
|
allPeerDepNames: new Set(['dep']),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>project>foo/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project' as PkgResolutionId,
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>project>foo/1.0.0>' as NodeId, {
|
|
children: {
|
|
dep: '>project>foo/1.0.0>dep/1.0.0>' as NodeId,
|
|
bar: '>project>foo/1.0.0>bar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 0,
|
|
}],
|
|
['>project>foo/1.0.0>dep/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'dep',
|
|
pkgIdWithPatchHash: 'dep/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 1,
|
|
}],
|
|
['>project>foo/1.0.0>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
dep: { version: '10' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
depth: 1,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})).peerDependencyIssuesByProjects
|
|
})
|
|
it('should return from where the bad peer dependency is resolved', () => {
|
|
expect(peerDependencyIssuesByProjects.project.bad.dep[0].resolvedFrom).toStrictEqual([{ name: 'foo', version: '1.0.0' }])
|
|
})
|
|
})
|
|
|
|
test('resolve peer dependencies with npm aliases', async () => {
|
|
const fooPkg = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
bar: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const fooAliasPkg = {
|
|
name: 'foo',
|
|
pkgIdWithPatchHash: 'foo/2.0.0' as PkgIdWithPatchHash,
|
|
version: '2.0.0',
|
|
peerDependencies: {
|
|
bar: { version: '2.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barPkg = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const barAliasPkg = {
|
|
name: 'bar',
|
|
pkgIdWithPatchHash: 'bar/2.0.0' as PkgIdWithPatchHash,
|
|
version: '2.0.0',
|
|
peerDependencies: {},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph } = await resolvePeers({
|
|
allPeerDepNames: new Set(['bar']),
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['foo', '>foo/1.0.0>' as NodeId],
|
|
['bar', '>bar/1.0.0>' as NodeId],
|
|
['foo-next', '>foo/2.0.0>' as NodeId],
|
|
['bar-next', '>bar/2.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: '' as PkgResolutionId,
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>foo/1.0.0>' as NodeId, {
|
|
children: {
|
|
bar: '>foo/1.0.0>bar/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: fooPkg,
|
|
depth: 0,
|
|
}],
|
|
['>foo/1.0.0>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 1,
|
|
}],
|
|
['>foo/2.0.0>' as NodeId, {
|
|
children: {
|
|
bar: '>foo/2.0.0>bar/2.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: fooAliasPkg,
|
|
depth: 0,
|
|
}],
|
|
['>foo/2.0.0>bar/2.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barAliasPkg,
|
|
depth: 1,
|
|
}],
|
|
['>bar/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barPkg,
|
|
depth: 0,
|
|
}],
|
|
['>bar/2.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: barAliasPkg,
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
|
|
'bar/1.0.0',
|
|
'bar/2.0.0',
|
|
'foo/1.0.0(bar/1.0.0)',
|
|
'foo/2.0.0(bar/2.0.0)',
|
|
])
|
|
})
|
|
|
|
describe('dedupePeers', () => {
|
|
test('uses version-only peer suffixes without nested dep paths', async () => {
|
|
// Simulates: react@18, @emotion/react@11(peer: react), @emotion/styled@11(peer: react, @emotion/react)
|
|
// Without dedupePeers: @emotion/styled gets suffix (@emotion/react@11(react@18))(react@18) — nested dep paths
|
|
// With dedupePeers: @emotion/styled gets suffix (@emotion/react@11.0.0)(react@18.0.0) — version-only, no nesting
|
|
const reactPkg = {
|
|
name: 'react',
|
|
pkgIdWithPatchHash: 'react/18.0.0' as PkgIdWithPatchHash,
|
|
version: '18.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const emotionReactPkg = {
|
|
name: '@emotion/react',
|
|
pkgIdWithPatchHash: '@emotion/react/11.0.0' as PkgIdWithPatchHash,
|
|
version: '11.0.0',
|
|
peerDependencies: {
|
|
react: { version: '>=16' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const emotionStyledPkg = {
|
|
name: '@emotion/styled',
|
|
pkgIdWithPatchHash: '@emotion/styled/11.0.0' as PkgIdWithPatchHash,
|
|
version: '11.0.0',
|
|
peerDependencies: {
|
|
react: { version: '>=16' },
|
|
'@emotion/react': { version: '>=11' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph } = await resolvePeers({
|
|
allPeerDepNames: new Set(['react', '@emotion/react', '@emotion/styled']),
|
|
dedupePeers: true,
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['react', '>react/18.0.0>' as NodeId],
|
|
['@emotion/react', '>@emotion/react/11.0.0>' as NodeId],
|
|
['@emotion/styled', '>@emotion/styled/11.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: '',
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>react/18.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: reactPkg,
|
|
depth: 0,
|
|
}],
|
|
['>@emotion/react/11.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: emotionReactPkg,
|
|
depth: 0,
|
|
}],
|
|
['>@emotion/styled/11.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: emotionStyledPkg,
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
|
|
'@emotion/react/11.0.0(react@18.0.0)',
|
|
'@emotion/styled/11.0.0(@emotion/react@11.0.0)(react@18.0.0)',
|
|
'react/18.0.0',
|
|
])
|
|
})
|
|
|
|
test('transitive peers use version-only suffixes', async () => {
|
|
// A depends on B (peer: C). A has no peers itself.
|
|
// Without dedupePeers: A gets suffix (c/1.0.0) — full dep path
|
|
// With dedupePeers: A gets suffix (c@1.0.0) — version-only
|
|
const aPkg = {
|
|
name: 'a',
|
|
pkgIdWithPatchHash: 'a/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const bPkg = {
|
|
name: 'b',
|
|
pkgIdWithPatchHash: 'b/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
c: { version: '1.0.0' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const cPkg = {
|
|
name: 'c',
|
|
pkgIdWithPatchHash: 'c/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph } = await resolvePeers({
|
|
allPeerDepNames: new Set(['c']),
|
|
dedupePeers: true,
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['a', '>a/1.0.0>' as NodeId],
|
|
['c', '>c/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: '',
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>a/1.0.0>' as NodeId, {
|
|
children: {
|
|
b: '>a/1.0.0>b/1.0.0>' as NodeId,
|
|
},
|
|
installable: true,
|
|
resolvedPackage: aPkg,
|
|
depth: 0,
|
|
}],
|
|
['>a/1.0.0>b/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: bPkg,
|
|
depth: 1,
|
|
}],
|
|
['>c/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: cPkg,
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
|
|
'a/1.0.0(c@1.0.0)',
|
|
'b/1.0.0(c@1.0.0)',
|
|
'c/1.0.0',
|
|
])
|
|
})
|
|
|
|
test('multi-project: different peer versions produce different instances', async () => {
|
|
// project-a has react@17, project-b has react@18
|
|
// Both have plugin@1 (peer: react)
|
|
const react17Pkg = {
|
|
name: 'react',
|
|
pkgIdWithPatchHash: 'react/17.0.0' as PkgIdWithPatchHash,
|
|
version: '17.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const react18Pkg = {
|
|
name: 'react',
|
|
pkgIdWithPatchHash: 'react/18.0.0' as PkgIdWithPatchHash,
|
|
version: '18.0.0',
|
|
peerDependencies: {} as PeerDependencies,
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const pluginPkg = {
|
|
name: 'plugin',
|
|
pkgIdWithPatchHash: 'plugin/1.0.0' as PkgIdWithPatchHash,
|
|
version: '1.0.0',
|
|
peerDependencies: {
|
|
react: { version: '>=16' },
|
|
},
|
|
id: '' as PkgResolutionId,
|
|
}
|
|
const { dependenciesGraph, dependenciesByProjectId } = await resolvePeers({
|
|
allPeerDepNames: new Set(['react']),
|
|
dedupePeers: true,
|
|
projects: [
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['react', '>project-a>react/17.0.0>' as NodeId],
|
|
['plugin', '>project-a>plugin/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project-a',
|
|
},
|
|
{
|
|
directNodeIdsByAlias: new Map([
|
|
['react', '>project-b>react/18.0.0>' as NodeId],
|
|
['plugin', '>project-b>plugin/1.0.0>' as NodeId],
|
|
]),
|
|
topParents: [],
|
|
rootDir: '' as ProjectRootDir,
|
|
id: 'project-b',
|
|
},
|
|
],
|
|
resolvedImporters: {},
|
|
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
|
|
['>project-a>react/17.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: react17Pkg,
|
|
depth: 0,
|
|
}],
|
|
['>project-a>plugin/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: pluginPkg,
|
|
depth: 0,
|
|
}],
|
|
['>project-b>react/18.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: react18Pkg,
|
|
depth: 0,
|
|
}],
|
|
['>project-b>plugin/1.0.0>' as NodeId, {
|
|
children: {},
|
|
installable: true,
|
|
resolvedPackage: pluginPkg,
|
|
depth: 0,
|
|
}],
|
|
]),
|
|
virtualStoreDir: '',
|
|
virtualStoreDirMaxLength: 120,
|
|
lockfileDir: '',
|
|
peersSuffixMaxLength: 1000,
|
|
workspaceProjectIds: new Set(),
|
|
})
|
|
// Plugin has two instances — one per react version
|
|
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
|
|
'plugin/1.0.0(react@17.0.0)',
|
|
'plugin/1.0.0(react@18.0.0)',
|
|
'react/17.0.0',
|
|
'react/18.0.0',
|
|
])
|
|
// Each project gets the correct instance
|
|
expect(dependenciesByProjectId['project-a'].get('plugin')).toBe('plugin/1.0.0(react@17.0.0)')
|
|
expect(dependenciesByProjectId['project-b'].get('plugin')).toBe('plugin/1.0.0(react@18.0.0)')
|
|
})
|
|
})
|