forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it } from 'vitest'
2import {
3 buildTaggedVersionRows,
4 buildVersionToTagsMap,
5 filterExcludedTags,
6 filterVersions,
7 getPrereleaseChannel,
8 getVersionGroupKey,
9 getVersionGroupLabel,
10 isExactVersion,
11 isSameVersionGroup,
12 parseVersion,
13 sortTags,
14} from '../../../../app/utils/versions'
15
16describe('isExactVersion', () => {
17 it('returns true for stable versions', () => {
18 expect(isExactVersion('1.0.0')).toBe(true)
19 expect(isExactVersion('0.1.0')).toBe(true)
20 expect(isExactVersion('10.20.30')).toBe(true)
21 })
22
23 it('returns true for prerelease versions', () => {
24 expect(isExactVersion('1.0.0-beta.1')).toBe(true)
25 expect(isExactVersion('1.0.0-alpha.0')).toBe(true)
26 expect(isExactVersion('5.8.0-rc')).toBe(true)
27 })
28
29 it('returns false for ranges', () => {
30 expect(isExactVersion('^1.0.0')).toBe(false)
31 expect(isExactVersion('~1.0.0')).toBe(false)
32 expect(isExactVersion('>=1.0.0')).toBe(false)
33 expect(isExactVersion('1.0.x')).toBe(false)
34 expect(isExactVersion('*')).toBe(false)
35 })
36
37 it('returns false for dist-tags', () => {
38 expect(isExactVersion('latest')).toBe(false)
39 expect(isExactVersion('next')).toBe(false)
40 expect(isExactVersion('beta')).toBe(false)
41 })
42
43 it('returns false for invalid strings', () => {
44 expect(isExactVersion('')).toBe(false)
45 expect(isExactVersion('not-a-version')).toBe(false)
46 })
47})
48
49describe('parseVersion', () => {
50 it('parses stable versions', () => {
51 expect(parseVersion('1.2.3')).toEqual({
52 major: 1,
53 minor: 2,
54 patch: 3,
55 prerelease: '',
56 })
57 })
58
59 it('parses prerelease versions', () => {
60 expect(parseVersion('1.0.0-beta.1')).toEqual({
61 major: 1,
62 minor: 0,
63 patch: 0,
64 prerelease: 'beta.1',
65 })
66 })
67
68 it('handles invalid versions gracefully', () => {
69 expect(parseVersion('invalid')).toEqual({
70 major: 0,
71 minor: 0,
72 patch: 0,
73 prerelease: '',
74 })
75 })
76
77 it('parses TypeScript-style versions', () => {
78 // TypeScript uses versions like 5.8.0-beta, 5.8.0-rc
79 expect(parseVersion('5.8.0-beta')).toEqual({
80 major: 5,
81 minor: 8,
82 patch: 0,
83 prerelease: 'beta',
84 })
85 })
86
87 it('parses Next.js canary versions', () => {
88 // Next.js uses versions like 15.3.0-canary.1
89 expect(parseVersion('15.3.0-canary.1')).toEqual({
90 major: 15,
91 minor: 3,
92 patch: 0,
93 prerelease: 'canary.1',
94 })
95 })
96})
97
98describe('getPrereleaseChannel', () => {
99 it('returns empty string for stable versions', () => {
100 expect(getPrereleaseChannel('1.0.0')).toBe('')
101 })
102
103 it('extracts beta channel', () => {
104 expect(getPrereleaseChannel('1.0.0-beta.1')).toBe('beta')
105 })
106
107 it('extracts alpha channel', () => {
108 expect(getPrereleaseChannel('1.0.0-alpha.1')).toBe('alpha')
109 })
110
111 it('extracts rc channel', () => {
112 expect(getPrereleaseChannel('4.0.0-rc.0')).toBe('rc')
113 })
114
115 it('extracts canary channel (Next.js style)', () => {
116 expect(getPrereleaseChannel('15.3.0-canary.1')).toBe('canary')
117 })
118
119 it('handles versions with just channel name (TypeScript style)', () => {
120 expect(getPrereleaseChannel('5.8.0-beta')).toBe('beta')
121 })
122})
123
124describe('sortTags', () => {
125 it('puts latest first', () => {
126 expect(sortTags(['beta', 'latest', 'alpha'])).toEqual(['latest', 'alpha', 'beta'])
127 })
128
129 it('sorts alphabetically when no latest', () => {
130 expect(sortTags(['beta', 'canary', 'alpha'])).toEqual(['alpha', 'beta', 'canary'])
131 })
132
133 it('handles single tag', () => {
134 expect(sortTags(['latest'])).toEqual(['latest'])
135 })
136
137 it('handles empty array', () => {
138 expect(sortTags([])).toEqual([])
139 })
140
141 it('does not mutate original array', () => {
142 const original = ['beta', 'latest']
143 sortTags(original)
144 expect(original).toEqual(['beta', 'latest'])
145 })
146})
147
148describe('buildVersionToTagsMap', () => {
149 it('builds map from simple dist-tags', () => {
150 const distTags = {
151 latest: '1.0.0',
152 beta: '2.0.0-beta.1',
153 }
154 const map = buildVersionToTagsMap(distTags)
155 expect(map.get('1.0.0')).toEqual(['latest'])
156 expect(map.get('2.0.0-beta.1')).toEqual(['beta'])
157 })
158
159 it('groups multiple tags pointing to same version', () => {
160 const distTags = {
161 latest: '1.0.0',
162 stable: '1.0.0',
163 lts: '1.0.0',
164 }
165 const map = buildVersionToTagsMap(distTags)
166 // Should be sorted with latest first, then alphabetically
167 expect(map.get('1.0.0')).toEqual(['latest', 'lts', 'stable'])
168 })
169
170 it('handles Nuxt dist-tags', () => {
171 // Real Nuxt dist-tags structure
172 const distTags = {
173 '1x': '1.4.5',
174 '2x': '2.18.1',
175 'alpha': '4.0.0-alpha.4',
176 'rc': '4.0.0-rc.0',
177 '3x': '3.21.0',
178 'latest': '4.3.0',
179 }
180 const map = buildVersionToTagsMap(distTags)
181 expect(map.get('4.3.0')).toEqual(['latest'])
182 expect(map.get('3.21.0')).toEqual(['3x'])
183 expect(map.size).toBe(6)
184 })
185
186 it('handles TypeScript dist-tags with overlapping versions', () => {
187 // Simulating a scenario where latest and next point to same version
188 const distTags = {
189 latest: '5.8.3',
190 next: '5.8.3',
191 beta: '5.9.0-beta',
192 rc: '5.9.0-rc',
193 }
194 const map = buildVersionToTagsMap(distTags)
195 expect(map.get('5.8.3')).toEqual(['latest', 'next'])
196 expect(map.get('5.9.0-beta')).toEqual(['beta'])
197 })
198
199 it('handles Next.js dist-tags', () => {
200 // Real Next.js dist-tags structure
201 const distTags = {
202 'latest': '15.2.4',
203 'canary': '15.3.0-canary.49',
204 'rc': '15.2.0-rc.2',
205 'experimental-react': '0.0.0-experimental-react',
206 }
207 const map = buildVersionToTagsMap(distTags)
208 expect(map.get('15.2.4')).toEqual(['latest'])
209 expect(map.get('15.3.0-canary.49')).toEqual(['canary'])
210 })
211
212 it('handles Vue dist-tags', () => {
213 // Vue uses v3-latest, etc.
214 const distTags = {
215 'latest': '3.5.13',
216 'next': '3.5.13',
217 'v2-latest': '2.7.16',
218 'csp': '1.0.28-csp',
219 }
220 const map = buildVersionToTagsMap(distTags)
221 // latest and next both point to 3.5.13
222 expect(map.get('3.5.13')).toEqual(['latest', 'next'])
223 expect(map.get('2.7.16')).toEqual(['v2-latest'])
224 })
225
226 it('handles React dist-tags', () => {
227 const distTags = {
228 latest: '19.1.0',
229 next: '19.1.0',
230 canary: '19.1.0-canary-xyz',
231 experimental: '0.0.0-experimental-xyz',
232 rc: '19.0.0-rc.1',
233 }
234 const map = buildVersionToTagsMap(distTags)
235 // latest and next both point to same version
236 expect(map.get('19.1.0')).toEqual(['latest', 'next'])
237 })
238})
239
240describe('buildTaggedVersionRows', () => {
241 it('builds rows sorted by version descending', () => {
242 const distTags = {
243 latest: '2.0.0',
244 beta: '3.0.0-beta.1',
245 legacy: '1.0.0',
246 }
247 const rows = buildTaggedVersionRows(distTags)
248 expect(rows.map(r => r.version)).toEqual(['3.0.0-beta.1', '2.0.0', '1.0.0'])
249 })
250
251 it('deduplicates versions with multiple tags', () => {
252 const distTags = {
253 latest: '1.0.0',
254 stable: '1.0.0',
255 beta: '2.0.0-beta.1',
256 }
257 const rows = buildTaggedVersionRows(distTags)
258 expect(rows).toHaveLength(2)
259 expect(rows[0]).toEqual({
260 id: 'version:2.0.0-beta.1',
261 primaryTag: 'beta',
262 tags: ['beta'],
263 version: '2.0.0-beta.1',
264 })
265 expect(rows[1]).toEqual({
266 id: 'version:1.0.0',
267 primaryTag: 'latest',
268 tags: ['latest', 'stable'],
269 version: '1.0.0',
270 })
271 })
272
273 it('uses latest as primary tag when present', () => {
274 const distTags = {
275 stable: '1.0.0',
276 latest: '1.0.0',
277 lts: '1.0.0',
278 }
279 const rows = buildTaggedVersionRows(distTags)
280 expect(rows[0]!.primaryTag).toBe('latest')
281 expect(rows[0]!.tags).toEqual(['latest', 'lts', 'stable'])
282 })
283
284 it('handles Vue scenario with latest and next on same version', () => {
285 const distTags = {
286 'latest': '3.5.13',
287 'next': '3.5.13',
288 'v2-latest': '2.7.16',
289 }
290 const rows = buildTaggedVersionRows(distTags)
291 expect(rows).toHaveLength(2)
292 // 3.5.13 should come first (higher version)
293 expect(rows[0]).toEqual({
294 id: 'version:3.5.13',
295 primaryTag: 'latest',
296 tags: ['latest', 'next'],
297 version: '3.5.13',
298 })
299 })
300
301 it('handles Nuxt scenario', () => {
302 const distTags = {
303 '1x': '1.4.5',
304 '2x': '2.18.1',
305 'alpha': '4.0.0-alpha.4',
306 'rc': '4.0.0-rc.0',
307 '3x': '3.21.0',
308 'latest': '4.3.0',
309 }
310 const rows = buildTaggedVersionRows(distTags)
311 expect(rows).toHaveLength(6)
312 // Check order: 4.3.0 > 4.0.0-rc.0 > 4.0.0-alpha.4 > 3.21.0 > 2.18.1 > 1.4.5
313 expect(rows.map(r => r.version)).toEqual([
314 '4.3.0',
315 '4.0.0-rc.0',
316 '4.0.0-alpha.4',
317 '3.21.0',
318 '2.18.1',
319 '1.4.5',
320 ])
321 expect(rows[0]!.tags).toEqual(['latest'])
322 })
323})
324
325describe('filterExcludedTags', () => {
326 it('filters out excluded tags', () => {
327 expect(filterExcludedTags(['latest', 'beta', 'rc'], ['latest'])).toEqual(['beta', 'rc'])
328 })
329
330 it('filters multiple excluded tags', () => {
331 expect(filterExcludedTags(['latest', 'next', 'beta'], ['latest', 'next'])).toEqual(['beta'])
332 })
333
334 it('returns all tags if none excluded', () => {
335 expect(filterExcludedTags(['latest', 'beta'], [])).toEqual(['latest', 'beta'])
336 })
337
338 it('returns empty if all excluded', () => {
339 expect(filterExcludedTags(['latest'], ['latest'])).toEqual([])
340 })
341
342 it('handles non-matching exclusions', () => {
343 expect(filterExcludedTags(['beta', 'rc'], ['latest'])).toEqual(['beta', 'rc'])
344 })
345})
346
347describe('getVersionGroupKey', () => {
348 it('groups 1.x+ versions by major only', () => {
349 expect(getVersionGroupKey('1.0.0')).toBe('1')
350 expect(getVersionGroupKey('1.5.3')).toBe('1')
351 expect(getVersionGroupKey('2.0.0')).toBe('2')
352 expect(getVersionGroupKey('10.5.2')).toBe('10')
353 })
354
355 it('groups 0.x versions by major.minor', () => {
356 expect(getVersionGroupKey('0.1.0')).toBe('0.1')
357 expect(getVersionGroupKey('0.1.5')).toBe('0.1')
358 expect(getVersionGroupKey('0.9.0')).toBe('0.9')
359 expect(getVersionGroupKey('0.9.3')).toBe('0.9')
360 expect(getVersionGroupKey('0.10.0')).toBe('0.10')
361 expect(getVersionGroupKey('0.10.5')).toBe('0.10')
362 })
363
364 it('handles prerelease versions', () => {
365 expect(getVersionGroupKey('1.0.0-beta.1')).toBe('1')
366 expect(getVersionGroupKey('0.5.0-alpha.1')).toBe('0.5')
367 })
368})
369
370describe('getVersionGroupLabel', () => {
371 it('formats 1.x+ group keys', () => {
372 expect(getVersionGroupLabel('1')).toBe('1.x')
373 expect(getVersionGroupLabel('2')).toBe('2.x')
374 expect(getVersionGroupLabel('10')).toBe('10.x')
375 })
376
377 it('formats 0.x group keys', () => {
378 expect(getVersionGroupLabel('0.1')).toBe('0.1.x')
379 expect(getVersionGroupLabel('0.9')).toBe('0.9.x')
380 expect(getVersionGroupLabel('0.10')).toBe('0.10.x')
381 })
382})
383
384describe('isSameVersionGroup', () => {
385 it('groups 1.x+ versions by major', () => {
386 expect(isSameVersionGroup('1.0.0', '1.5.3')).toBe(true)
387 expect(isSameVersionGroup('1.0.0', '1.99.99')).toBe(true)
388 expect(isSameVersionGroup('2.0.0', '2.1.0')).toBe(true)
389 })
390
391 it('separates different major versions', () => {
392 expect(isSameVersionGroup('1.0.0', '2.0.0')).toBe(false)
393 expect(isSameVersionGroup('1.5.3', '2.0.0')).toBe(false)
394 })
395
396 it('groups 0.x versions by major.minor', () => {
397 expect(isSameVersionGroup('0.1.0', '0.1.5')).toBe(true)
398 expect(isSameVersionGroup('0.9.0', '0.9.3')).toBe(true)
399 expect(isSameVersionGroup('0.10.0', '0.10.5')).toBe(true)
400 })
401
402 it('separates different 0.x minor versions', () => {
403 // This is the key test: 0.9.x should NOT be grouped with 0.10.x
404 expect(isSameVersionGroup('0.9.0', '0.10.0')).toBe(false)
405 expect(isSameVersionGroup('0.9.3', '0.10.5')).toBe(false)
406 expect(isSameVersionGroup('0.1.0', '0.2.0')).toBe(false)
407 })
408
409 it('separates 0.x from 1.x', () => {
410 expect(isSameVersionGroup('0.9.0', '1.0.0')).toBe(false)
411 expect(isSameVersionGroup('0.99.99', '1.0.0')).toBe(false)
412 })
413
414 it('handles prerelease versions in 1.x+', () => {
415 expect(isSameVersionGroup('1.0.0-beta.1', '1.0.0')).toBe(true)
416 expect(isSameVersionGroup('1.0.0-alpha.1', '1.5.0')).toBe(true)
417 })
418
419 it('handles prerelease versions in 0.x', () => {
420 expect(isSameVersionGroup('0.5.0-beta.1', '0.5.0')).toBe(true)
421 expect(isSameVersionGroup('0.5.0-alpha.1', '0.5.3')).toBe(true)
422 expect(isSameVersionGroup('0.5.0-beta.1', '0.6.0')).toBe(false)
423 })
424})
425
426describe('filterVersions', () => {
427 const versions = ['1.0.0', '1.1.0', '1.5.3', '2.0.0', '2.1.0', '3.0.0-beta.1']
428
429 it('returns all versions for empty range', () => {
430 expect(filterVersions(versions, '')).toEqual(new Set(versions))
431 })
432
433 it('returns all versions for whitespace-only range', () => {
434 expect(filterVersions(versions, ' ')).toEqual(new Set(versions))
435 })
436
437 it('matches exact version', () => {
438 expect(filterVersions(versions, '1.0.0')).toEqual(new Set(['1.0.0']))
439 })
440
441 it('matches caret range', () => {
442 expect(filterVersions(versions, '^1.0.0')).toEqual(new Set(['1.0.0', '1.1.0', '1.5.3']))
443 })
444
445 it('matches tilde range', () => {
446 expect(filterVersions(versions, '~1.0.0')).toEqual(new Set(['1.0.0']))
447 expect(filterVersions(versions, '~1.1.0')).toEqual(new Set(['1.1.0']))
448 })
449
450 it('matches complex range', () => {
451 // 3.0.0-beta.1 is included because with includePrerelease it is < 3.0.0
452 expect(filterVersions(versions, '>=2.0.0 <3.0.0')).toEqual(
453 new Set(['2.0.0', '2.1.0', '3.0.0-beta.1']),
454 )
455 })
456
457 it('matches prerelease versions with includePrerelease', () => {
458 expect(filterVersions(versions, '>=3.0.0-beta.0')).toEqual(new Set(['3.0.0-beta.1']))
459 })
460
461 it('returns empty set for invalid range', () => {
462 expect(filterVersions(versions, 'not-a-range!!!')).toEqual(new Set())
463 })
464
465 it('returns empty set for empty versions array', () => {
466 expect(filterVersions([], '^1.0.0')).toEqual(new Set())
467 })
468})