mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 17:35:30 -04:00
The TypeScript pnpm CLI freezes at v11; pnpm 12 will be the Rust pacquet port. To make that split legible, all TypeScript source, test, and build directories move under a new top-level pnpm11/ directory. The name states the version boundary rather than implying a behavioral fork, since the two stacks are meant to behave identically. Scope is source-only: the shared workspace root stays at the repo root. pnpm-workspace.yaml, package.json, pnpm-lock.yaml, .pnpmfile.cjs, .meta-updater, __patches__, .changeset, .husky, and the lint/spell configs remain in place, so one pnpm workspace and one Cargo workspace still span all three products. pnpr/client and pacquet/tasks/registry-mock stay as cross-product workspace members. Rewiring the move required: - pnpm-workspace.yaml globs prefixed with pnpm11/ - root package.json script paths, eslint.config.mjs, tsconfig.lint.json, .gitignore, and CODEOWNERS updated - .meta-updater/src/index.ts literals repointed (pnpm11/pnpm/package.json, pnpm11/__utils__, pnpm11/__typings__, and the main package directory) - regenerated every moved package's repository/homepage URL via meta-updater - pnpm11/pnpm/bundle-deps.ts and __utils__/scripts/src/typecheck-only.ts climb one more level to reach the repo root .meta-updater stays at the repo root because @pnpm/meta-updater resolves its config at <cwd>/.meta-updater/main.mjs. TS CI (.github/workflows/ci.yml) now only runs when pnpm11/-relevant paths change, via a dorny/paths-filter changes job plus a TS CI / Success aggregate gate; branch protection should require only that gate.
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
/// <reference path="../../../__typings__/index.d.ts"/>
|
|
import { expect, jest, test } from '@jest/globals'
|
|
import type { CustomResolver, WantedDependency } from '@pnpm/hooks.types'
|
|
import { createResolver } from '@pnpm/resolving.default-resolver'
|
|
|
|
test('custom resolver intercepts matching packages', async () => {
|
|
const customResolver: CustomResolver = {
|
|
canResolve: (wantedDependency: WantedDependency) => {
|
|
return wantedDependency.alias === 'test-package'
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
resolve: async (wantedDependency: WantedDependency, _opts: any) => {
|
|
return {
|
|
id: `custom:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
|
|
resolution: {
|
|
type: 'directory',
|
|
directory: '/test/path',
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const fetchFromRegistry = async (): Promise<Response> => new Response('')
|
|
const getAuthHeader = () => undefined
|
|
|
|
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'test-package', bareSpecifier: '1.0.0' },
|
|
{
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: {},
|
|
}
|
|
)
|
|
|
|
expect(result.id).toBe('custom:test-package@1.0.0')
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
})
|
|
|
|
test('custom resolver with synchronous methods', async () => {
|
|
const customResolver: CustomResolver = {
|
|
// Synchronous support check
|
|
canResolve: (wantedDependency: WantedDependency) => {
|
|
return wantedDependency.alias!.startsWith('@sync/')
|
|
},
|
|
// Synchronous resolution
|
|
resolve: (wantedDependency: WantedDependency) => {
|
|
return {
|
|
id: `sync:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
|
|
resolution: {
|
|
tarball: `file://${wantedDependency.alias}-${wantedDependency.bareSpecifier}.tgz`,
|
|
integrity: 'sha512-test',
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const fetchFromRegistry = async (): Promise<Response> => new Response('')
|
|
const getAuthHeader = () => undefined
|
|
|
|
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: '@sync/test', bareSpecifier: '2.0.0' },
|
|
{
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: {},
|
|
}
|
|
)
|
|
|
|
expect(result.id).toBe('sync:@sync/test@2.0.0')
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
})
|
|
|
|
test('multiple custom resolvers - first matching wins', async () => {
|
|
const resolver1: CustomResolver = {
|
|
canResolve: (wantedDependency) => wantedDependency.alias === 'shared-package',
|
|
resolve: () => ({
|
|
id: 'resolver-1:shared-package',
|
|
resolution: { tarball: 'file://resolver1.tgz', integrity: 'sha512-1' },
|
|
}),
|
|
}
|
|
|
|
const resolver2: CustomResolver = {
|
|
canResolve: (wantedDependency) => wantedDependency.alias === 'shared-package',
|
|
resolve: () => ({
|
|
id: 'resolver-2:shared-package',
|
|
resolution: { tarball: 'file://resolver2.tgz', integrity: 'sha512-2' },
|
|
}),
|
|
}
|
|
|
|
const fetchFromRegistry = async (): Promise<Response> => new Response('')
|
|
const getAuthHeader = () => undefined
|
|
|
|
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
|
|
customResolvers: [resolver1, resolver2], // Order matters
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'shared-package', bareSpecifier: '1.0.0' },
|
|
{
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: {},
|
|
}
|
|
)
|
|
|
|
// First custom resolver should win
|
|
expect(result.id).toBe('resolver-1:shared-package')
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
})
|
|
|
|
test('custom resolver error handling', async () => {
|
|
const customResolver: CustomResolver = {
|
|
canResolve: () => true,
|
|
resolve: () => {
|
|
throw new Error('Custom resolver failed')
|
|
},
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
await expect(resolve({ alias: 'any', bareSpecifier: '1.0.0' }, { lockfileDir: '/test', projectDir: '/test', preferredVersions: {} })).rejects.toThrow('Custom resolver failed')
|
|
})
|
|
|
|
test('preferredVersions are passed to custom resolver', async () => {
|
|
const resolve = jest.fn<NonNullable<CustomResolver['resolve']>>(() => ({
|
|
id: 'test@1.0.0',
|
|
resolution: { tarball: 'file://test.tgz', integrity: 'sha512-test' },
|
|
}))
|
|
const customResolver: CustomResolver = {
|
|
canResolve: () => true,
|
|
resolve,
|
|
}
|
|
|
|
const { resolve: resolvePackage } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
await resolvePackage(
|
|
{ alias: 'any', bareSpecifier: '1.0.0' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: { any: { '1.0.0': 'version' } } as unknown as Record<string, Record<string, 'version' | 'range' | 'tag'>> }
|
|
)
|
|
|
|
expect(resolve).toHaveBeenCalledWith(
|
|
{ alias: 'any', bareSpecifier: '1.0.0' },
|
|
expect.objectContaining({
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: { any: { '1.0.0': 'version' } },
|
|
})
|
|
)
|
|
})
|
|
|
|
test('custom resolver can intercept any protocol', async () => {
|
|
const customResolver: CustomResolver = {
|
|
canResolve: (wantedDependency: WantedDependency) => {
|
|
return wantedDependency.alias!.startsWith('custom-')
|
|
},
|
|
resolve: (wantedDependency: WantedDependency) => ({
|
|
id: `custom-handled:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
|
|
resolution: {
|
|
type: 'custom:test',
|
|
directory: `/custom/${wantedDependency.alias}`,
|
|
},
|
|
}),
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'custom-package', bareSpecifier: 'file:../some-path' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
expect(result.id).toBe('custom-handled:custom-package@file:../some-path')
|
|
})
|
|
|
|
test('custom resolver falls through when not supported', async () => {
|
|
const customResolver: CustomResolver = {
|
|
canResolve: (wantedDependency: WantedDependency) => {
|
|
return wantedDependency.alias!.startsWith('custom-')
|
|
},
|
|
resolve: (wantedDependency: WantedDependency) => ({
|
|
id: `custom:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
|
|
resolution: { type: 'custom:test', directory: '/custom' },
|
|
}),
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
await expect(
|
|
resolve(
|
|
{ alias: 'regular-package', bareSpecifier: 'file:../nonexistent' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
test('custom resolver can override npm registry resolution', async () => {
|
|
const npmStyleResolver: CustomResolver = {
|
|
canResolve: (wantedDependency) => {
|
|
return !wantedDependency.bareSpecifier!.includes(':')
|
|
},
|
|
resolve: (wantedDependency) => ({
|
|
id: `custom-registry:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
|
|
resolution: {
|
|
tarball: `https://custom-registry.com/${wantedDependency.alias}/-/${wantedDependency.alias}-${wantedDependency.bareSpecifier}.tgz`,
|
|
integrity: 'sha512-custom',
|
|
},
|
|
}),
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [npmStyleResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
storeDir: '.store',
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'express', bareSpecifier: '^4.0.0' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
expect('tarball' in result.resolution && result.resolution.tarball).toContain('custom-registry.com')
|
|
})
|
|
|
|
// Fetch phase custom fetcher tests - showing complete fetcher replacements
|
|
|
|
test('custom custom fetcher: reuse local tarball fetcher', async () => {
|
|
// This demonstrates how a custom resolver can reuse pnpm's local tarball fetcher
|
|
// for a custom protocol like "company-local:package-name"
|
|
const localTarballResolver: CustomResolver = {
|
|
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('company-local:'),
|
|
resolve: (wantedDependency) => {
|
|
const actualName = wantedDependency.alias!.replace('company-local:', '')
|
|
return {
|
|
id: wantedDependency.alias!,
|
|
resolution: {
|
|
type: 'custom:local',
|
|
localPath: `/company/tarballs/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [localTarballResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
storeDir: '.store',
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'company-local:my-package', bareSpecifier: '1.0.0' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
expect(result.resolution).toHaveProperty('type', 'custom:local')
|
|
})
|
|
|
|
test('custom custom fetcher: reuse remote tarball downloader', async () => {
|
|
// This demonstrates fetching from a custom CDN using pnpm's download utilities
|
|
// for a custom protocol like "cdn:package-name"
|
|
const cdnResolver: CustomResolver = {
|
|
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('cdn:'),
|
|
resolve: (wantedDependency) => {
|
|
const actualName = wantedDependency.alias!.replace('cdn:', '')
|
|
return {
|
|
id: wantedDependency.alias!,
|
|
resolution: {
|
|
type: 'custom:cdn',
|
|
cdnUrl: `https://cdn.example.com/packages/${actualName}/${wantedDependency.bareSpecifier}/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [cdnResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
storeDir: '.store',
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'cdn:awesome-lib', bareSpecifier: '2.0.0' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
expect(result.resolution).toHaveProperty('type', 'custom:cdn')
|
|
})
|
|
|
|
test('custom custom fetcher: wrap npm registry with custom logic', async () => {
|
|
// This demonstrates wrapping/enhancing standard npm registry resolution and fetching
|
|
// for a protocol like "private-npm:package-name" that uses private registry
|
|
const privateNpmResolver: CustomResolver = {
|
|
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('private-npm:'),
|
|
resolve: async (wantedDependency, _opts) => {
|
|
const actualName = wantedDependency.alias!.replace('private-npm:', '')
|
|
|
|
// In a real implementation, you'd fetch from your private registry here
|
|
// For this test, we mock the registry response
|
|
return {
|
|
id: `private-npm:${actualName}@${wantedDependency.bareSpecifier}`,
|
|
resolution: {
|
|
tarball: `https://private-registry.company.com/${actualName}/-/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
|
|
integrity: 'sha512-mock-integrity',
|
|
registry: 'https://private-registry.company.com/',
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
|
|
customResolvers: [privateNpmResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
storeDir: '.store',
|
|
})
|
|
|
|
const result = await resolve(
|
|
{ alias: 'private-npm:company-utils', bareSpecifier: '3.0.0' },
|
|
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
|
|
)
|
|
|
|
expect(result.resolvedVia).toBe('custom-resolver')
|
|
expect('tarball' in result.resolution && result.resolution.tarball).toContain('private-registry.company.com')
|
|
})
|
|
|
|
test('custom resolver receives currentPkg when provided', async () => {
|
|
let receivedCurrentPkg: unknown = null
|
|
|
|
const customResolver: CustomResolver = {
|
|
canResolve: (wantedDependency: WantedDependency) => {
|
|
return wantedDependency.alias === 'test-package'
|
|
},
|
|
resolve: async (wantedDependency: WantedDependency, opts) => {
|
|
receivedCurrentPkg = opts.currentPkg
|
|
// If currentPkg is provided, return existing resolution
|
|
if (opts.currentPkg) {
|
|
return {
|
|
id: opts.currentPkg.id,
|
|
resolution: opts.currentPkg.resolution,
|
|
}
|
|
}
|
|
return {
|
|
id: `custom:${wantedDependency.alias}@1.0.0`,
|
|
resolution: {
|
|
type: 'directory',
|
|
directory: '/test/path',
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
const fetchFromRegistry = async (): Promise<Response> => new Response('')
|
|
const getAuthHeader = () => undefined
|
|
|
|
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
|
|
customResolvers: [customResolver],
|
|
cacheDir: '/tmp/test-cache',
|
|
offline: false,
|
|
preferOffline: false,
|
|
retry: {},
|
|
timeout: 60000,
|
|
registries: { default: 'https://registry.npmjs.org/' },
|
|
})
|
|
|
|
// First call without currentPkg
|
|
const result1 = await resolve(
|
|
{ alias: 'test-package', bareSpecifier: '1.0.0' },
|
|
{
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: {},
|
|
}
|
|
)
|
|
|
|
expect(result1.id).toBe('custom:test-package@1.0.0')
|
|
expect(receivedCurrentPkg).toBeUndefined()
|
|
|
|
// Second call with currentPkg
|
|
const existingResolution = {
|
|
type: 'directory' as const,
|
|
directory: '/existing/path',
|
|
}
|
|
const result2 = await resolve(
|
|
{ alias: 'test-package', bareSpecifier: '1.0.0' },
|
|
{
|
|
lockfileDir: '/test',
|
|
projectDir: '/test',
|
|
preferredVersions: {},
|
|
currentPkg: {
|
|
id: 'existing:test-package@1.0.0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
resolution: existingResolution,
|
|
},
|
|
}
|
|
)
|
|
|
|
expect(receivedCurrentPkg).toBeTruthy()
|
|
expect((receivedCurrentPkg as any).id).toBe('existing:test-package@1.0.0') // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
expect(result2.id).toBe('existing:test-package@1.0.0')
|
|
expect(result2.resolution).toBe(existingResolution)
|
|
})
|