Skip to content

Commit f7734e7

Browse files
committed
fix: resolve semantic versioning issue
1 parent eb4d862 commit f7734e7

File tree

2 files changed

+166
-1
lines changed

2 files changed

+166
-1
lines changed

app/composables/npm/useResolvedVersion.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ResolvedPackageVersion } from 'fast-npm-meta'
1+
import type { PackageVersionsInfo, ResolvedPackageVersion } from 'fast-npm-meta'
22

33
export function useResolvedVersion(
44
packageName: MaybeRefOrGetter<string>,
@@ -13,6 +13,20 @@ export function useResolvedVersion(
1313
? `https://npm.antfu.dev/${name}@${version}`
1414
: `https://npm.antfu.dev/${name}`
1515
const data = await $fetch<ResolvedPackageVersion>(url)
16+
17+
// The fast-npm-meta API echoes back non-existent exact versions without
18+
// error (no publishedAt, no validation). When publishedAt is missing for
19+
// an exact version request, cross-check the versions list to confirm the
20+
// version actually exists in the registry.
21+
if (version && /^\d/.test(version) && !data.publishedAt) {
22+
const versionsData = await $fetch<PackageVersionsInfo>(
23+
`https://npm.antfu.dev/versions/${name}`,
24+
)
25+
if (!versionsData.versions.includes(version)) {
26+
return undefined
27+
}
28+
}
29+
1630
return data.version
1731
},
1832
{ default: () => undefined },
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { PackageVersionsInfo, ResolvedPackageVersion } from 'fast-npm-meta'
3+
4+
function makeResolvedVersion(
5+
overrides: Partial<ResolvedPackageVersion> = {},
6+
): ResolvedPackageVersion {
7+
return {
8+
name: 'axios',
9+
version: '1.7.9',
10+
specifier: '1.7.9',
11+
publishedAt: '2024-12-04T07:38:16.833Z',
12+
lastSynced: 1712345678,
13+
...overrides,
14+
}
15+
}
16+
17+
function makeVersionsInfo(versions: string[]): PackageVersionsInfo {
18+
return {
19+
name: 'axios',
20+
specifier: '*',
21+
distTags: { latest: versions.at(-1) ?? '' },
22+
versions,
23+
time: { created: '2010-01-01', modified: '2024-12-04' },
24+
lastSynced: 1712345678,
25+
}
26+
}
27+
28+
describe('useResolvedVersion', () => {
29+
let fetchSpy: ReturnType<typeof vi.fn>
30+
31+
beforeEach(() => {
32+
fetchSpy = vi.fn()
33+
vi.stubGlobal('$fetch', fetchSpy)
34+
})
35+
36+
afterEach(() => {
37+
vi.unstubAllGlobals()
38+
})
39+
40+
// Each test uses a unique package name to avoid sharing useAsyncData cache keys.
41+
42+
it('fetches without version suffix when no version is requested', async () => {
43+
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-no-version' }))
44+
45+
const { data, status } = useResolvedVersion('pkg-no-version', null)
46+
47+
await vi.waitFor(() => expect(status.value).toBe('success'))
48+
49+
expect(fetchSpy).toHaveBeenCalledOnce()
50+
expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/pkg-no-version')
51+
expect(data.value).toBe('1.7.9')
52+
})
53+
54+
it('appends the requested dist-tag to the URL', async () => {
55+
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-dist-tag', specifier: 'latest' }))
56+
57+
const { status } = useResolvedVersion('pkg-dist-tag', 'latest')
58+
59+
await vi.waitFor(() => expect(status.value).toBe('success'))
60+
61+
expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/pkg-dist-tag@latest')
62+
})
63+
64+
it('returns the resolved version for a valid exact version with publishedAt', async () => {
65+
fetchSpy.mockResolvedValue(makeResolvedVersion({ name: 'pkg-valid-version' }))
66+
67+
const { data, status } = useResolvedVersion('pkg-valid-version', '1.7.9')
68+
69+
await vi.waitFor(() => expect(status.value).toBe('success'))
70+
71+
// publishedAt is present — no second fetch needed
72+
expect(fetchSpy).toHaveBeenCalledOnce()
73+
expect(data.value).toBe('1.7.9')
74+
})
75+
76+
it('returns undefined for a non-existent exact version', async () => {
77+
// The API echoes back non-existent versions without publishedAt
78+
fetchSpy
79+
.mockResolvedValueOnce(
80+
makeResolvedVersion({
81+
name: 'pkg-nonexistent',
82+
version: '150.150.150',
83+
specifier: '150.150.150',
84+
publishedAt: null,
85+
}),
86+
)
87+
.mockResolvedValueOnce(makeVersionsInfo(['1.6.0', '1.7.9']))
88+
89+
const { data, status } = useResolvedVersion('pkg-nonexistent', '150.150.150')
90+
91+
await vi.waitFor(() => expect(status.value).toBe('success'))
92+
93+
expect(fetchSpy).toHaveBeenCalledTimes(2)
94+
expect(fetchSpy).toHaveBeenNthCalledWith(1, 'https://npm.antfu.dev/pkg-nonexistent@150.150.150')
95+
expect(fetchSpy).toHaveBeenNthCalledWith(2, 'https://npm.antfu.dev/versions/pkg-nonexistent')
96+
expect(data.value).toBeUndefined()
97+
})
98+
99+
it('returns the version for an old package version with no publishedAt that is in the registry', async () => {
100+
// Some registry entries lack publishedAt; the versions list is the source of truth
101+
fetchSpy
102+
.mockResolvedValueOnce(
103+
makeResolvedVersion({
104+
name: 'pkg-old-version',
105+
version: '0.1.0',
106+
specifier: '0.1.0',
107+
publishedAt: null,
108+
}),
109+
)
110+
.mockResolvedValueOnce(makeVersionsInfo(['0.1.0', '0.2.0', '1.0.0']))
111+
112+
const { data, status } = useResolvedVersion('pkg-old-version', '0.1.0')
113+
114+
await vi.waitFor(() => expect(status.value).toBe('success'))
115+
116+
expect(fetchSpy).toHaveBeenCalledTimes(2)
117+
expect(data.value).toBe('0.1.0')
118+
})
119+
120+
it('does not cross-check dist-tags against the versions list', async () => {
121+
// Dist-tags start with a letter — the /^\d/ guard short-circuits the check
122+
fetchSpy.mockResolvedValue(
123+
makeResolvedVersion({
124+
name: 'pkg-dist-tag-next',
125+
version: '1.7.0-beta.2',
126+
specifier: 'next',
127+
publishedAt: null,
128+
}),
129+
)
130+
131+
const { data, status } = useResolvedVersion('pkg-dist-tag-next', 'next')
132+
133+
await vi.waitFor(() => expect(status.value).toBe('success'))
134+
135+
expect(fetchSpy).toHaveBeenCalledOnce()
136+
expect(data.value).toBe('1.7.0-beta.2')
137+
})
138+
139+
it('handles scoped package names correctly', async () => {
140+
fetchSpy.mockResolvedValue(
141+
makeResolvedVersion({ name: '@test-scope/pkg', version: '3.5.0', specifier: '3.5.0' }),
142+
)
143+
144+
const { data, status } = useResolvedVersion('@test-scope/pkg', '3.5.0')
145+
146+
await vi.waitFor(() => expect(status.value).toBe('success'))
147+
148+
expect(fetchSpy).toHaveBeenCalledWith('https://npm.antfu.dev/@test-scope/pkg@3.5.0')
149+
expect(data.value).toBe('3.5.0')
150+
})
151+
})

0 commit comments

Comments
 (0)