fix: propagate error cause when throwing PnpmError in @pnpm/npm-resolver (#10990)

* fix: show error cause when failing to read metadata

* fix: correct changeset package name and add cause assertion tests

- Fix changeset to reference @pnpm/resolving.npm-resolver (not @pnpm/npm-resolver)
- Add PnpmError cause unit tests in @pnpm/error
- Fix npm-resolver tests to actually verify cause on thrown errors
  (.toThrow() only checks message, not cause/hint/code properties)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Cheng
2026-03-21 19:59:52 -04:00
committed by GitHub
parent 6586604b19
commit 831f574330
7 changed files with 58 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/resolving.npm-resolver": patch
---
When package metadata is malformed or can't be fetched, the error thrown will now show the originating error.

View File

@@ -0,0 +1,5 @@
---
"@pnpm/error": minor
---
The `PnpmError` class now accepts an optional `cause` argument.

View File

@@ -12,9 +12,10 @@ export class PnpmError extends Error {
opts?: {
attempts?: number
hint?: string
cause?: unknown
}
) {
super(message)
super(message, { cause: opts?.cause })
this.code = code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}`
this.hint = opts?.hint
this.attempts = opts?.attempts

View File

@@ -1,4 +1,22 @@
import { FetchError } from '@pnpm/error'
import { FetchError, PnpmError } from '@pnpm/error'
test('PnpmError exposes cause when provided', () => {
const cause = new Error('original failure')
const error = new PnpmError('TEST_CODE', 'something went wrong', { cause })
expect(error.cause).toBe(cause)
expect(error.message).toBe('something went wrong')
expect(error.code).toBe('ERR_PNPM_TEST_CODE')
})
test('PnpmError cause is undefined when omitted', () => {
const error = new PnpmError('TEST_CODE', 'something went wrong')
expect(error.cause).toBeUndefined()
})
test('PnpmError cause works with non-Error values', () => {
const error = new PnpmError('TEST_CODE', 'something went wrong', { cause: 'string cause' })
expect(error.cause).toBe('string cause')
})
test('FetchError escapes auth tokens', () => {
const error = new FetchError(

View File

@@ -86,7 +86,7 @@ export async function fetchMetadataFromFromRegistry (
timeout: fetchOpts.timeout,
}) as RegistryResponse
} catch (error: any) { // eslint-disable-line
reject(new PnpmError('META_FETCH_FAIL', `GET ${uri}: ${error.message as string}`, { attempts: attempt }))
reject(new PnpmError('META_FETCH_FAIL', `GET ${uri}: ${error.message as string}`, { attempts: attempt, cause: error }))
return
}
if (response.status >= 400) {

View File

@@ -90,7 +90,7 @@ export function pickPackageFromMeta (
}
throw new PnpmError('MALFORMED_METADATA',
`Received malformed metadata for "${spec.name}"`,
{ hint: 'This might mean that the package was unpublished from the registry' }
{ hint: 'This might mean that the package was unpublished from the registry', cause: err }
)
}
}

View File

@@ -865,8 +865,19 @@ test('error is thrown when registry not responding', async () => {
default: notExistingRegistry,
},
})
await expect(resolveFromNpm({ alias: notExistingPackage, bareSpecifier: '1.0.0' }, {})).rejects
.toThrow(new PnpmError('META_FETCH_FAIL', `GET ${notExistingRegistry}/${notExistingPackage}: request to ${notExistingRegistry}/${notExistingPackage} failed, reason: getaddrinfo ENOTFOUND not-existing.pnpm.io`, { attempts: 1 }))
let thrown: any // eslint-disable-line
try {
await resolveFromNpm({ alias: notExistingPackage, bareSpecifier: '1.0.0' }, {})
} catch (err) {
thrown = err
}
expect(thrown).toBeTruthy()
expect(thrown.code).toBe('ERR_PNPM_META_FETCH_FAIL')
expect(thrown.message).toContain(`GET ${notExistingRegistry}/${notExistingPackage}:`)
expect(thrown.message).toContain('ENOTFOUND')
expect(thrown.cause).toBeTruthy()
expect(thrown.cause.code).toBe('ENOTFOUND')
})
test('extra info is shown if package has valid semver appended', async () => {
@@ -1961,10 +1972,18 @@ test('request to a package with no dist-tags', async () => {
registries,
})
await expect(resolveFromNpm({ alias: 'is-positive' }, {})).rejects
.toThrow(
new PnpmError('MALFORMED_METADATA', 'Received malformed metadata for "is-positive"')
)
let thrown: any // eslint-disable-line
try {
await resolveFromNpm({ alias: 'is-positive' }, {})
} catch (err) {
thrown = err
}
expect(thrown).toBeTruthy()
expect(thrown.code).toBe('ERR_PNPM_MALFORMED_METADATA')
expect(thrown.message).toBe('Received malformed metadata for "is-positive"')
expect(thrown.hint).toBe('This might mean that the package was unpublished from the registry')
expect(thrown.cause).toBeTruthy()
expect(thrown.cause.message).toContain("Cannot read properties of undefined (reading 'latest')")
})
test('resolveFromNpm() does not fail if the meta file contains no integrity information', async () => {