[READ-ONLY] a fast, modern browser for the npm registry
at main 468 lines 14 kB view raw
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})