mirror of
https://github.com/pnpm/pnpm.git
synced 2026-01-23 06:18:40 -05:00
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
/// <reference path="../../../__typings__/index.d.ts"/>
|
|
import { jest } from '@jest/globals'
|
|
import { createResolver } from '@pnpm/default-resolver'
|
|
import { type WantedDependency, type CustomResolver } from '@pnpm/hooks.types'
|
|
import { Response } from 'node-fetch'
|
|
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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(() => ({
|
|
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],
|
|
rawConfig: {},
|
|
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' }, { 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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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],
|
|
rawConfig: {},
|
|
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)
|
|
})
|