Files
renovate/lib/modules/manager/npm/extract/pnpm.spec.ts
Michael Kriese a4a5efa8c2 feat(manager/npm): support pnpm v11.23 url-keyed registries (#45844)
pnpm v11.23 reshaped the `registries` setting in `pnpm-workspace.yaml`: it
is now keyed by the registry URL, with the scopes routed to it listed in the
entry. Renovate only accepted the older `<scope>: <url>` shape, so parsing of
the whole workspace file failed and its catalogs and overrides were no longer
extracted at all.

Accept both shapes and flatten the new one into the old one, where the bare
`@` scope is the scope-less default registry. URL keys which interpolate an
env var, embed credentials, or are unparseable are skipped, matching what
pnpm itself ignores or refuses. A `registries` value which is neither shape
now falls back to `undefined` instead of taking the file's catalogs with it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:27:20 +00:00

802 lines
23 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { Fixtures } from '~test/fixtures.ts';
import { fs, getFixturePath, logger, partial } from '~test/util.ts';
import { GlobalConfig } from '../../../../config/global.ts';
import * as yaml from '../../../../util/yaml.ts';
import type { PackageFile } from '../../types.ts';
import { PnpmWorkspaceFile } from '../schema.ts';
import type { NpmManagerData } from '../types.ts';
import {
detectPnpmWorkspaces,
extractPnpmFilters,
extractPnpmWorkspaceFile,
findPnpmWorkspace,
getPnpmLock,
resolveRegistryUrl,
} from './pnpm.ts';
vi.mock('../../../../util/fs/index.ts');
describe('modules/manager/npm/extract/pnpm', () => {
beforeAll(() => {
GlobalConfig.set({ localDir: getFixturePath('pnpm-monorepo/', '..') });
});
beforeEach(() => {
vi.restoreAllMocks();
});
describe('.extractPnpmFilters()', () => {
it('detects errors in pnpm-workspace.yml file structure', async () => {
fs.readLocalFile.mockResolvedValueOnce('p!!!ckages:\n - "packages/*"');
const workSpaceFilePath = getFixturePath(
'pnpm-monorepo/pnpm-workspace.yml',
'..',
);
const res = await extractPnpmFilters(workSpaceFilePath);
expect(res).toBeUndefined();
expect(logger.logger.trace).toHaveBeenCalledWith(
{
fileName: expect.any(String),
},
'Failed to find required "packages" array in pnpm-workspace.yaml',
);
});
it('detects errors when opening pnpm-workspace.yml file', async () => {
vi.spyOn(yaml, 'parseSingleYaml').mockImplementationOnce(() => {
throw new Error();
});
const res = await extractPnpmFilters('pnpm-workspace.yml');
expect(res).toBeUndefined();
expect(logger.logger.trace).toHaveBeenCalledWith(
expect.objectContaining({
fileName: expect.any(String),
err: expect.anything(),
}),
'Failed to parse pnpm-workspace.yaml',
);
});
});
describe('.findPnpmWorkspace()', () => {
it('detects missing pnpm-workspace.yaml', async () => {
fs.findLocalSiblingOrParent.mockResolvedValueOnce(null);
const packageFile = 'package.json';
const res = await findPnpmWorkspace(packageFile);
expect(res).toBeNull();
expect(logger.logger.trace).toHaveBeenCalledWith(
expect.objectContaining({ packageFile }),
'Failed to locate pnpm-workspace.yaml in a parent directory.',
);
});
it('detects missing pnpm-lock.yaml when pnpm-workspace.yaml was already found', async () => {
fs.findLocalSiblingOrParent.mockResolvedValueOnce('pnpm-workspace.yaml');
fs.localPathExists.mockResolvedValueOnce(false);
const packageFile = 'package.json';
const res = await findPnpmWorkspace(packageFile);
expect(res).toBeNull();
expect(logger.logger.trace).toHaveBeenCalledWith(
expect.objectContaining({
workspaceYamlPath: 'pnpm-workspace.yaml',
packageFile,
}),
'Failed to find a pnpm-lock.yaml sibling for the workspace.',
);
});
});
describe('.detectPnpmWorkspaces()', () => {
beforeEach(async () => {
const realFs = await vi.importActual<typeof fs>(
'../../../../util/fs/index.ts',
);
// The real implementations of these functions are used for this block;
// they do static path manipulation.
fs.findLocalSiblingOrParent.mockImplementation(
realFs.findLocalSiblingOrParent,
);
fs.getSiblingFileName.mockImplementation(realFs.getSiblingFileName);
// Falls through to reading from the fixture path defined in GlobalConfig
// at the top of this file
fs.readLocalFile.mockImplementation(realFs.readLocalFile);
});
it('uses pnpm workspaces', async () => {
fs.localPathExists.mockResolvedValue(true);
const packageFiles = partial<PackageFile<NpmManagerData>>([
{
packageFile: 'package.json',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
{
packageFile: 'nested-packages/group/a/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/nested-group-a',
},
},
{
packageFile: 'nested-packages/group/b/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/nested-group-b',
},
},
{
packageFile: 'non-nested-packages/a/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/non-nested-a',
},
},
{
packageFile: 'non-nested-packages/b/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/non-nested-b',
},
},
{
packageFile: 'solo-package/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/solo',
},
},
{
packageFile: 'solo-package-leading-dot-slash/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/solo-leading-dot-slash',
},
},
{
packageFile: 'solo-package-leading-double-dot-slash/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/solo-leading-double-dot-slash',
},
},
{
packageFile: 'solo-package-trailing-slash/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/solo-trailing-slash',
},
},
{
packageFile: 'test/test-package/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/test-package',
},
},
{
packageFile: 'tests/test-package2/package.json',
managerData: {
pnpmLockFile: undefined,
packageJsonName: '@demo/test-package2',
},
},
]);
await detectPnpmWorkspaces(packageFiles);
// every workspace package file gets associated with the root lockfile
expect(packageFiles).toMatchObject(
packageFiles.map(() => ({
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
})),
);
expect(
packageFiles.every(
(packageFile) => packageFile.managerData?.pnpmLockFile !== undefined,
),
).toBeTrue();
});
it('skips when pnpm shrinkwrap file has already been provided', async () => {
const packageFiles = [
{
packageFile: 'package.json',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
];
await detectPnpmWorkspaces(packageFiles);
expect(packageFiles).toEqual([
{
packageFile: 'package.json',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
]);
});
it('skips packages without a pnpm workspace', async () => {
fs.localPathExists.mockResolvedValue(false);
const packageFiles = [
{
packageFile: 'nested-packages/group/a/package.json',
packageJsonName: '@demo/nested-group-a',
managerData: { pnpmLockFile: undefined },
},
];
await detectPnpmWorkspaces(packageFiles);
expect(packageFiles[0].managerData.pnpmLockFile).toBeUndefined();
});
it('filters none matching packages', async () => {
fs.localPathExists.mockResolvedValue(true);
const packageFiles = [
{
packageFile: 'package.json',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
{
packageFile: 'nested-packages/group/a/package.json',
packageJsonName: '@demo/nested-group-a',
managerData: { pnpmLockFile: undefined },
},
{
packageFile: 'not-matching/b/package.json',
packageJsonName: '@not-matching/b',
managerData: { pnpmLockFile: undefined },
},
];
await detectPnpmWorkspaces(packageFiles);
expect(packageFiles).toEqual([
{
packageFile: 'package.json',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
{
packageFile: 'nested-packages/group/a/package.json',
packageJsonName: '@demo/nested-group-a',
managerData: { pnpmLockFile: 'pnpm-lock.yaml' },
},
{
packageFile: 'not-matching/b/package.json',
packageJsonName: '@not-matching/b',
managerData: { pnpmLockFile: undefined },
},
]);
expect(
packageFiles.find(
(packageFile) =>
packageFile.packageFile === 'not-matching/b/package.json',
)?.managerData.pnpmLockFile,
).toBeUndefined();
});
});
describe('.getPnpmLock()', () => {
it('returns empty if failed to parse', async () => {
fs.readLocalFile.mockResolvedValueOnce(undefined as never);
const res = await getPnpmLock('package.json');
expect(res.lockedVersionsWithPath).toBeUndefined();
});
it('extracts version from monorepo', async () => {
const plocktest1Lock = Fixtures.get('pnpm-monorepo/pnpm-lock.yaml', '..');
fs.readLocalFile.mockResolvedValueOnce(plocktest1Lock);
const res = await getPnpmLock('package.json');
expect(Object.keys(res.lockedVersionsWithPath!)).toHaveLength(11);
});
it('extracts version from normal repo', async () => {
const plocktest1Lock = Fixtures.get(
'lockfile-parsing/pnpm-lock.yaml',
'..',
);
fs.readLocalFile.mockResolvedValueOnce(plocktest1Lock);
const res = await getPnpmLock('package.json');
expect(Object.keys(res.lockedVersionsWithPath!)).toHaveLength(1);
});
it('extracts version from catalogs', async () => {
const lockfileContent = codeBlock`
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
catalogs:
default:
react:
specifier: ^18
version: 18.3.1
importers:
.:
dependencies:
react:
specifier: 'catalog:'
version: 18.3.1
packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
snapshots:
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react@18.3.1:
dependencies:
loose-envify: 1.4.0
`;
fs.readLocalFile.mockResolvedValueOnce(lockfileContent);
const res = await getPnpmLock('package.json');
expect(Object.keys(res.lockedVersionsWithCatalog!)).toHaveLength(1);
});
it('returns empty if no deps', async () => {
fs.readLocalFile.mockResolvedValueOnce('{}');
const res = await getPnpmLock('package.json');
expect(res.lockedVersionsWithPath).toBeUndefined();
});
it('extracts version from multi-document lockfile (configDependencies)', async () => {
const lockfileContent = codeBlock`
---
lockfileVersion: '9.0'
importers:
.:
configDependencies:
'@scope/pnpm-plugin-defaults':
specifier: 0.1.0
version: 0.1.0
packages:
'@scope/pnpm-plugin-defaults@0.1.0':
resolution: {integrity: sha512-aaa==}
snapshots:
'@scope/pnpm-plugin-defaults@0.1.0': {}
---
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
lodash:
specifier: ^4.17.20
version: 4.18.1
packages:
lodash@4.18.1:
resolution: {integrity: sha512-bbb==}
snapshots:
lodash@4.18.1: {}
`;
fs.readLocalFile.mockResolvedValueOnce(lockfileContent);
const res = await getPnpmLock('package.json');
expect(Object.keys(res.lockedVersionsWithPath!)).toHaveLength(1);
expect(res.lockedVersionsWithPath!['.'].dependencies.lodash).toBe(
'4.18.1',
);
});
});
describe('.extractPnpmWorkspaceFile()', () => {
it('handles empty catalog entries', async () => {
await expect(
extractPnpmWorkspaceFile(
{ catalog: {}, catalogs: {} },
'pnpm-workspace.yaml',
),
).resolves.toMatchObject({
deps: [],
});
});
it('parses valid pnpm-workspace.yaml file', async () => {
await expect(
extractPnpmWorkspaceFile(
{
catalog: {
react: '18.3.0',
},
catalogs: {
react17: {
react: '17.0.2',
},
},
},
'pnpm-workspace.yaml',
),
).resolves.toMatchObject({
deps: [
{
currentValue: '18.3.0',
datasource: 'npm',
depName: 'react',
depType: 'pnpm.catalog.default',
prettyDepType: 'pnpm.catalog.default',
},
{
currentValue: '17.0.2',
datasource: 'npm',
depName: 'react',
depType: 'pnpm.catalog.react17',
prettyDepType: 'pnpm.catalog.react17',
},
],
});
});
it('parses overrides in pnpm-workspace.yaml file', async () => {
await expect(
extractPnpmWorkspaceFile(
{
overrides: {
'foo>bar': '2.0.0',
'foo@1.0.0': '2.0.0',
'foo@>1.0.0': '2.0.0',
'foo@>=1.0.0': '2.0.0',
'foo@1.0.0>bar': '2.0.0',
'foo@>1.0.0>bar': '2.0.0',
'foo@>=1.0.0 <2.0.0': '>=2.0.0',
},
},
'pnpm-workspace.yaml',
),
).resolves.toMatchObject({
deps: [
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo>bar',
depType: 'pnpm-workspace.overrides',
packageName: 'bar',
},
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo@1.0.0',
depType: 'pnpm-workspace.overrides',
packageName: 'foo',
},
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo@>1.0.0',
depType: 'pnpm-workspace.overrides',
packageName: 'foo',
},
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo@>=1.0.0',
depType: 'pnpm-workspace.overrides',
packageName: 'foo',
},
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo@1.0.0>bar',
depType: 'pnpm-workspace.overrides',
packageName: 'bar',
},
{
currentValue: '2.0.0',
datasource: 'npm',
depName: 'foo@>1.0.0>bar',
depType: 'pnpm-workspace.overrides',
packageName: 'bar',
},
{
currentValue: '>=2.0.0',
datasource: 'npm',
depName: 'foo@>=1.0.0 <2.0.0',
depType: 'pnpm-workspace.overrides',
packageName: 'foo',
},
],
});
});
it('applies scoped registry from registries to catalog deps', async () => {
await expect(
extractPnpmWorkspaceFile(
{
catalog: {
'@my-org/pkg': '1.0.0',
react: '18.3.0',
},
registries: {
'@my-org': 'https://private.example.com/',
},
},
'pnpm-workspace.yaml',
),
).resolves.toMatchObject({
deps: [
{
depName: '@my-org/pkg',
registryUrls: ['https://private.example.com/'],
},
{
depName: 'react',
},
],
});
});
it('applies top-level registry to all npm catalog deps', async () => {
const res = await extractPnpmWorkspaceFile(
{
catalog: {
'@my-org/pkg': '1.0.0',
react: '18.3.0',
},
registry: 'https://private.example.com/',
},
'pnpm-workspace.yaml',
);
expect(res?.deps.flatMap((dep) => dep.registryUrls)).toEqual([
'https://private.example.com/',
'https://private.example.com/',
]);
});
it('strips registries values containing env vars when parsing', async () => {
const parsed = await PnpmWorkspaceFile.safeParseAsync(codeBlock`
registries:
"@my-org": https://\${TOKEN}.example.com/
"@other-org": https://private.example.com/
`);
expect(parsed.data?.registries).toEqual({
'@other-org': 'https://private.example.com/',
});
});
it('applies url-keyed registries to catalog deps', async () => {
const parsed = await PnpmWorkspaceFile.safeParseAsync(codeBlock`
catalog:
"@my-org/pkg": 1.0.0
react: 18.3.0
registries:
https://private.example.com/:
serverType: artifactory
scopes: ["@my-org", "@my-other-org"]
prefix: work
https://default.example.com/:
scopes: ["@"]
supportsTimeField: true
`);
await expect(
extractPnpmWorkspaceFile(parsed.data!, 'pnpm-workspace.yaml'),
).resolves.toMatchObject({
deps: [
{
depName: '@my-org/pkg',
registryUrls: ['https://private.example.com/'],
},
{
depName: 'react',
registryUrls: ['https://default.example.com/'],
},
],
});
});
it('skips unusable url-keyed registries when parsing', async () => {
const parsed = await PnpmWorkspaceFile.safeParseAsync(codeBlock`
registries:
https://\${TOKEN}.example.com/:
scopes: ["@env-var-org"]
https://user:pass@credentials.example.com/:
scopes: ["@credentials-org"]
not-a-url:
scopes: ["@invalid-url-org"]
https://unrouted.example.com/:
https://private.example.com/:
scopes: ["@my-org"]
`);
expect(parsed.data?.registries).toEqual({
'@my-org': 'https://private.example.com/',
});
});
it('ignores registries which mix both shapes', async () => {
const parsed = await PnpmWorkspaceFile.safeParseAsync(codeBlock`
catalog:
react: 18.3.0
registries:
"@my-org": https://private.example.com/
https://default.example.com/:
scopes: ["@"]
`);
expect(parsed.data?.registries).toBeUndefined();
expect(parsed.data?.catalog).toEqual({ react: '18.3.0' });
});
it('does not apply registries to non-npm catalog deps', async () => {
const res = await extractPnpmWorkspaceFile(
{
catalog: {
a: 'github:owner/a#v1.1.0',
},
registry: 'https://private.example.com/',
},
'pnpm-workspace.yaml',
);
expect(res?.deps).toMatchObject([
{ depName: 'a', datasource: 'github-tags' },
]);
expect(res?.deps[0].registryUrls).toBeUndefined();
});
it('resolves override registry using the package name, not the selector', async () => {
// depName starts with `@my-org/` but the real target package is `bar`,
// so the `@my-org` scoped registry must NOT apply.
const res = await extractPnpmWorkspaceFile(
{
overrides: {
'@my-org/foo>bar': '2.0.0',
},
registries: {
'@my-org': 'https://private.example.com/',
},
},
'pnpm-workspace.yaml',
);
expect(res?.deps).toMatchObject([
{
depName: '@my-org/foo>bar',
packageName: 'bar',
},
]);
expect(res?.deps[0].registryUrls).toBeUndefined();
});
it('finds relevant lockfile', async () => {
const lockfileContent = codeBlock`
lockfileVersion: '9.0'
catalogs:
default:
react:
specifier: 18.3.1
version: 18.3.1
importers:
.:
dependencies:
react:
specifier: 'catalog:'
version: 18.3.1
packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
snapshots:
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react@18.3.1:
dependencies:
loose-envify: 1.4.0
`;
fs.readLocalFile.mockResolvedValueOnce(lockfileContent);
fs.getSiblingFileName.mockReturnValueOnce('pnpm-lock.yaml');
await expect(
extractPnpmWorkspaceFile(
{
catalog: {
react: '18.3.1',
},
},
'pnpm-workspace.yaml',
),
).resolves.toMatchObject({
managerData: {
pnpmLockFile: 'pnpm-lock.yaml',
},
});
});
});
describe('.resolveRegistryUrl()', () => {
it('returns the scoped registry for a matching scope', () => {
expect(
resolveRegistryUrl(
'@my-org/pkg',
{ '@my-org': 'https://private.example.com/' },
undefined,
),
).toBe('https://private.example.com/');
});
it('falls back to registries.default for an unscoped package', () => {
expect(
resolveRegistryUrl(
'react',
{ default: 'https://default.example.com/' },
undefined,
),
).toBe('https://default.example.com/');
});
it('falls back to the default registry for a non-matching scope', () => {
expect(
resolveRegistryUrl(
'@other/pkg',
{ '@my-org': 'https://private.example.com/' },
'https://default.example.com/',
),
).toBe('https://default.example.com/');
});
it('falls back to the top-level registry when no registries are set', () => {
expect(
resolveRegistryUrl('react', undefined, 'https://default.example.com/'),
).toBe('https://default.example.com/');
});
it('returns null when nothing matches', () => {
expect(resolveRegistryUrl('react', undefined, undefined)).toBeNull();
});
});
});