Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion app/composables/npm/useResolvedVersion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ResolvedPackageVersion } from 'fast-npm-meta'
import type { PackageVersionsInfo, ResolvedPackageVersion } from 'fast-npm-meta'
import semver from 'semver'

export function useResolvedVersion(
packageName: MaybeRefOrGetter<string>,
Expand All @@ -13,6 +14,20 @@ export function useResolvedVersion(
? `https://npm.antfu.dev/${name}@${version}`
: `https://npm.antfu.dev/${name}`
const data = await $fetch<ResolvedPackageVersion>(url)

// The fast-npm-meta API echoes back non-existent exact versions without
// error (no publishedAt, no validation). When publishedAt is missing for
// an exact version request, cross-check the versions list to confirm the
// version actually exists in the registry.
Comment on lines +18 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd almost rather fix this upstream, want to make a PR?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can make a PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a PR in upstream, if there is any issue, please let me know

if (version && semver.valid(version) && !data.publishedAt) {
const versionsData = await $fetch<PackageVersionsInfo>(
`https://npm.antfu.dev/versions/${name}`,
)
if (!versionsData.versions.includes(version)) {
return undefined
}
}

return data.version
},
{ default: () => undefined },
Expand Down
151 changes: 151 additions & 0 deletions test/nuxt/composables/use-resolved-version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PackageVersionsInfo, ResolvedPackageVersion } from 'fast-npm-meta'

function makeResolvedVersion(
overrides: Partial<ResolvedPackageVersion> = {},
): ResolvedPackageVersion {
return {
name: 'axios',
version: '1.7.9',
specifier: '1.7.9',
publishedAt: '2024-12-04T07:38:16.833Z',
lastSynced: 1712345678,
...overrides,
}
}

function makeVersionsInfo(versions: string[]): PackageVersionsInfo {
return {
name: 'axios',
specifier: '*',
distTags: { latest: versions.at(-1) ?? '' },
versions,
time: { created: '2010-01-01', modified: '2024-12-04' },
lastSynced: 1712345678,
}
}

describe('useResolvedVersion', () => {
let fetchSpy: ReturnType<typeof vi.fn>

beforeEach(() => {
fetchSpy = vi.fn()
vi.stubGlobal('$fetch', fetchSpy)
})

afterEach(() => {
vi.unstubAllGlobals()
})

// Each test uses a unique package name to avoid sharing useAsyncData cache keys.

it('fetches without version suffix when no version is requested', async () => {
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-no-version' }))

const { data, status } = useResolvedVersion('pkg-no-version', null)

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledOnce()
expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/pkg-no-version')
expect(data.value).toBe('1.7.9')
})

it('appends the requested dist-tag to the URL', async () => {
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-dist-tag', specifier: 'latest' }))

const { status } = useResolvedVersion('pkg-dist-tag', 'latest')

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/pkg-dist-tag@latest')
})

it('returns the resolved version for a valid exact version with publishedAt', async () => {
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-valid-version' }))

const { data, status } = useResolvedVersion('pkg-valid-version', '1.7.9')

await vi.waitFor(() => expect(status.value).toBe('success'))

// publishedAt is present — no second fetch needed
expect(fetchSpy).toHaveBeenCalledOnce()
expect(data.value).toBe('1.7.9')
})

it('returns undefined for a non-existent exact version', async () => {
// The API echoes back non-existent versions without publishedAt
fetchSpy
.mockResolvedValueOnce(
makeResolvedVersion({
name: 'pkg-nonexistent',
version: '150.150.150',
specifier: '150.150.150',
publishedAt: null,
}),
)
.mockResolvedValueOnce(makeVersionsInfo(['1.6.0', '1.7.9']))

const { data, status } = useResolvedVersion('pkg-nonexistent', '150.150.150')

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(fetchSpy).toHaveBeenNthCalledWith(1, 'https://npm.antfu.dev/pkg-nonexistent@150.150.150')
expect(fetchSpy).toHaveBeenNthCalledWith(2, 'https://npm.antfu.dev/versions/pkg-nonexistent')
expect(data.value).toBeUndefined()
})

it('returns the version for an old package version with no publishedAt that is in the registry', async () => {
// Some registry entries lack publishedAt; the versions list is the source of truth
fetchSpy
.mockResolvedValueOnce(
makeResolvedVersion({
name: 'pkg-old-version',
version: '0.1.0',
specifier: '0.1.0',
publishedAt: null,
}),
)
.mockResolvedValueOnce(makeVersionsInfo(['0.1.0', '0.2.0', '1.0.0']))

const { data, status } = useResolvedVersion('pkg-old-version', '0.1.0')

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(data.value).toBe('0.1.0')
})

it('does not cross-check dist-tags against the versions list', async () => {
// Dist-tags start with a letter
fetchSpy.mockResolvedValue(
makeResolvedVersion({
name: 'pkg-dist-tag-next',
version: '1.7.0-beta.2',
specifier: 'next',
publishedAt: null,
}),
)

const { data, status } = useResolvedVersion('pkg-dist-tag-next', 'next')

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledOnce()
expect(data.value).toBe('1.7.0-beta.2')
})

it('handles scoped package names correctly', async () => {
fetchSpy.mockResolvedValue(
makeResolvedVersion({ name: '@test-scope/pkg', version: '3.5.0', specifier: '3.5.0' }),
)

const { data, status } = useResolvedVersion('@test-scope/pkg', '3.5.0')

await vi.waitFor(() => expect(status.value).toBe('success'))

expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/@test-scope/pkg@3.5.0')
expect(data.value).toBe('3.5.0')
})
})
Loading