forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it, vi, beforeEach } from 'vitest'
2import { mountSuspended } from '@nuxt/test-utils/runtime'
3import PackageVersions from '~/components/Package/Versions.vue'
4import type { SlimVersion } from '#shared/types'
5
6// Mock the fetchAllPackageVersions function
7const mockFetchAllPackageVersions = vi.fn()
8vi.mock('~/utils/npm/api', () => ({
9 fetchAllPackageVersions: (...args: unknown[]) => mockFetchAllPackageVersions(...args),
10}))
11
12/**
13 * Helper to create a minimal SlimVersion for testing
14 */
15function createVersion(
16 version: string,
17 options: {
18 deprecated?: string
19 hasProvenance?: boolean
20 } = {},
21): SlimVersion {
22 return {
23 version,
24 deprecated: options.deprecated,
25 tags: undefined,
26 ...(options.hasProvenance ? { hasProvenance: true } : {}),
27 } as SlimVersion
28}
29
30describe('PackageVersions', () => {
31 beforeEach(() => {
32 mockFetchAllPackageVersions.mockReset()
33 })
34
35 describe('basic rendering', () => {
36 it('renders the Versions section', async () => {
37 const component = await mountSuspended(PackageVersions, {
38 props: {
39 packageName: 'test-package',
40 versions: {
41 '1.0.0': createVersion('1.0.0'),
42 },
43 distTags: { latest: '1.0.0' },
44 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
45 },
46 })
47
48 expect(component.find('#versions').exists()).toBe(true)
49 })
50
51 it('does not render when there are no dist-tags', async () => {
52 const component = await mountSuspended(PackageVersions, {
53 props: {
54 packageName: 'test-package',
55 versions: {},
56 distTags: {},
57 time: {},
58 },
59 })
60
61 expect(component.find('section').exists()).toBe(false)
62 })
63
64 it('renders version links with correct routes', async () => {
65 const component = await mountSuspended(PackageVersions, {
66 props: {
67 packageName: 'test-package',
68 versions: {
69 '2.0.0': createVersion('2.0.0'),
70 },
71 distTags: { latest: '2.0.0' },
72 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
73 },
74 })
75
76 // Find version links (exclude anchor links that start with # and external links)
77 const versionLinks = component
78 .findAll('a')
79 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
80 expect(versionLinks.length).toBeGreaterThan(0)
81 expect(versionLinks[0]?.text()).toBe('2.0.0')
82 })
83
84 it('renders scoped package version links correctly', async () => {
85 const component = await mountSuspended(PackageVersions, {
86 props: {
87 packageName: '@scope/test-package',
88 versions: {
89 '1.0.0': createVersion('1.0.0'),
90 },
91 distTags: { latest: '1.0.0' },
92 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
93 },
94 })
95
96 // Find version links (exclude anchor links that start with # and external links)
97 const versionLinks = component
98 .findAll('a')
99 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
100 expect(versionLinks.length).toBeGreaterThan(0)
101 expect(versionLinks[0]?.text()).toBe('1.0.0')
102 })
103 })
104
105 describe('dist-tag display', () => {
106 it('displays dist-tag labels below version', async () => {
107 const component = await mountSuspended(PackageVersions, {
108 props: {
109 packageName: 'test-package',
110 versions: {
111 '1.0.0': createVersion('1.0.0'),
112 },
113 distTags: { latest: '1.0.0' },
114 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
115 },
116 })
117
118 expect(component.text()).toContain('latest')
119 })
120
121 it('displays multiple tags for same version', async () => {
122 const component = await mountSuspended(PackageVersions, {
123 props: {
124 packageName: 'test-package',
125 versions: {
126 '1.0.0': createVersion('1.0.0'),
127 },
128 distTags: {
129 latest: '1.0.0',
130 stable: '1.0.0',
131 },
132 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
133 },
134 })
135
136 const text = component.text()
137 expect(text).toContain('latest')
138 expect(text).toContain('stable')
139 })
140
141 it('shows "latest" tag first when multiple tags exist', async () => {
142 const component = await mountSuspended(PackageVersions, {
143 props: {
144 packageName: 'test-package',
145 versions: {
146 '1.0.0': createVersion('1.0.0'),
147 },
148 distTags: {
149 alpha: '1.0.0',
150 latest: '1.0.0',
151 beta: '1.0.0',
152 },
153 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
154 },
155 })
156
157 // Find all tag spans
158 const tagSpans = component.findAll('span').filter(span => {
159 const text = span.text()
160 return text === 'latest' || text === 'alpha' || text === 'beta'
161 })
162
163 expect(tagSpans.length).toBeGreaterThanOrEqual(3)
164 // latest should be first
165 expect(tagSpans[0]?.text()).toBe('latest')
166 })
167
168 it('sorts tag rows by version descending', async () => {
169 const component = await mountSuspended(PackageVersions, {
170 props: {
171 packageName: 'test-package',
172 versions: {
173 '1.0.0': createVersion('1.0.0'),
174 '2.0.0': createVersion('2.0.0'),
175 '1.5.0': createVersion('1.5.0'),
176 },
177 distTags: {
178 old: '1.0.0',
179 latest: '2.0.0',
180 stable: '1.5.0',
181 },
182 time: {
183 '1.0.0': '2024-01-01T00:00:00.000Z',
184 '1.5.0': '2024-01-10T00:00:00.000Z',
185 '2.0.0': '2024-01-15T00:00:00.000Z',
186 },
187 },
188 })
189
190 // Find version links (exclude anchor links that start with # and external links)
191 const versionLinks = component
192 .findAll('a')
193 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
194 const versions = versionLinks.map(l => l.text())
195 // Should be sorted by version descending
196 expect(versions[0]).toBe('2.0.0')
197 })
198 })
199
200 describe('deprecated versions', () => {
201 it('applies deprecated styling to deprecated versions', async () => {
202 const component = await mountSuspended(PackageVersions, {
203 props: {
204 packageName: 'test-package',
205 versions: {
206 '1.0.0': createVersion('1.0.0', { deprecated: 'Use 2.0.0 instead' }),
207 },
208 distTags: { latest: '1.0.0' },
209 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
210 },
211 })
212
213 // Find version links (exclude anchor links that start with # and external links)
214 const versionLinks = component
215 .findAll('a')
216 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
217 expect(versionLinks.length).toBeGreaterThan(0)
218 expect(versionLinks[0]?.classes()).toContain('text-red-800')
219 })
220
221 it('shows deprecated version in title attribute', async () => {
222 const component = await mountSuspended(PackageVersions, {
223 props: {
224 packageName: 'test-package',
225 versions: {
226 '1.0.0': createVersion('1.0.0', { deprecated: 'Use 2.0.0 instead' }),
227 },
228 distTags: { latest: '1.0.0' },
229 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
230 },
231 })
232
233 // Find version links (exclude anchor links that start with # and external links)
234 const versionLinks = component
235 .findAll('a')
236 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
237 expect(versionLinks.length).toBeGreaterThan(0)
238 expect(versionLinks[0]?.attributes('title')).toContain('deprecated')
239 })
240
241 it('filters deprecated tags from visible list when package is not deprecated', async () => {
242 const component = await mountSuspended(PackageVersions, {
243 props: {
244 packageName: 'test-package',
245 versions: {
246 '1.0.0': createVersion('1.0.0', { deprecated: 'Old version' }),
247 '2.0.0': createVersion('2.0.0'),
248 },
249 distTags: {
250 old: '1.0.0',
251 latest: '2.0.0',
252 },
253 time: {
254 '1.0.0': '2024-01-01T00:00:00.000Z',
255 '2.0.0': '2024-01-15T00:00:00.000Z',
256 },
257 },
258 })
259
260 // The deprecated version should not appear in visible tags (only in "Other versions")
261 const visibleLinks = component.findAll('a').filter(l => l.text() === '1.0.0')
262 expect(visibleLinks.length).toBe(0)
263 })
264 })
265
266 describe('provenance badge', () => {
267 it('shows provenance badge for versions with attestations', async () => {
268 const component = await mountSuspended(PackageVersions, {
269 props: {
270 packageName: 'test-package',
271 versions: {
272 '1.0.0': createVersion('1.0.0', { hasProvenance: true }),
273 },
274 distTags: { latest: '1.0.0' },
275 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
276 },
277 })
278
279 // ProvenanceBadge component should be rendered
280 const provenanceBadge = component.findComponent({ name: 'ProvenanceBadge' })
281 expect(provenanceBadge.exists()).toBe(true)
282 })
283
284 it('does not show provenance badge for versions without attestations', async () => {
285 const component = await mountSuspended(PackageVersions, {
286 props: {
287 packageName: 'test-package',
288 versions: {
289 '1.0.0': createVersion('1.0.0', { hasProvenance: false }),
290 },
291 distTags: { latest: '1.0.0' },
292 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
293 },
294 })
295
296 const provenanceBadge = component.findComponent({ name: 'ProvenanceBadge' })
297 expect(provenanceBadge.exists()).toBe(false)
298 })
299 })
300
301 describe('datetime display', () => {
302 it('shows DateTime component for versions with time', async () => {
303 const component = await mountSuspended(PackageVersions, {
304 props: {
305 packageName: 'test-package',
306 versions: {
307 '1.0.0': createVersion('1.0.0'),
308 },
309 distTags: { latest: '1.0.0' },
310 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
311 },
312 })
313
314 const dateTime = component.findComponent({ name: 'DateTime' })
315 expect(dateTime.exists()).toBe(true)
316 })
317 })
318
319 describe('expand/collapse tag rows', () => {
320 it('shows expand button for tag rows', async () => {
321 const component = await mountSuspended(PackageVersions, {
322 props: {
323 packageName: 'test-package',
324 versions: {
325 '1.0.0': createVersion('1.0.0'),
326 },
327 distTags: { latest: '1.0.0' },
328 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
329 },
330 })
331
332 const expandButton = component.find('[data-testid="tag-expand-button"]')
333 expect(expandButton.exists()).toBe(true)
334 expect(expandButton.attributes('aria-expanded')).toBe('false')
335 })
336
337 it('has correct aria-label on expand button', async () => {
338 const component = await mountSuspended(PackageVersions, {
339 props: {
340 packageName: 'test-package',
341 versions: {
342 '1.0.0': createVersion('1.0.0'),
343 },
344 distTags: { latest: '1.0.0' },
345 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
346 },
347 })
348
349 const expandButton = component.find('[data-testid="tag-expand-button"]')
350 expect(expandButton.attributes('aria-label')).toBe('Expand latest')
351 })
352
353 it('loads versions and toggles expanded state on click', async () => {
354 mockFetchAllPackageVersions.mockResolvedValue([
355 { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
356 { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
357 ])
358
359 const component = await mountSuspended(PackageVersions, {
360 props: {
361 packageName: 'test-package',
362 versions: {
363 '1.0.0': createVersion('1.0.0'),
364 },
365 distTags: { latest: '1.0.0' },
366 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
367 },
368 })
369
370 const expandButton = component.find('[data-testid="tag-expand-button"]')
371 await expandButton.trigger('click')
372
373 // Wait for async operation
374 await vi.waitFor(() => {
375 expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package')
376 })
377 })
378
379 it('collapses when clicking expanded row', async () => {
380 // Return multiple versions so the expand button stays visible after loading
381 mockFetchAllPackageVersions.mockResolvedValue([
382 { version: '1.2.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
383 { version: '1.1.0', time: '2024-01-12T12:00:00.000Z', hasProvenance: false },
384 { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
385 ])
386
387 const component = await mountSuspended(PackageVersions, {
388 props: {
389 packageName: 'test-package',
390 versions: {
391 '1.2.0': createVersion('1.2.0'),
392 },
393 distTags: { latest: '1.2.0' },
394 time: { '1.2.0': '2024-01-15T12:00:00.000Z' },
395 },
396 })
397
398 // Get initial expand button
399 const expandButton = component.find('[data-testid="tag-expand-button"]')
400 expect(expandButton.exists()).toBe(true)
401
402 // Expand
403 await expandButton.trigger('click')
404
405 // Wait for versions to load and expansion to happen
406 await vi.waitFor(() => {
407 expect(mockFetchAllPackageVersions).toHaveBeenCalled()
408 })
409
410 // Wait for the component to update after loading
411 await vi.waitFor(
412 () => {
413 const btn = component.find('[data-testid="tag-expand-button"][aria-expanded="true"]')
414 expect(btn.exists()).toBe(true)
415 },
416 { timeout: 2000 },
417 )
418
419 // Now collapse by clicking again
420 const expandedButton = component.find(
421 '[data-testid="tag-expand-button"][aria-expanded="true"]',
422 )
423 await expandedButton.trigger('click')
424
425 await vi.waitFor(
426 () => {
427 const btn = component.find('[data-testid="tag-expand-button"][aria-expanded="false"]')
428 expect(btn.exists()).toBe(true)
429 },
430 { timeout: 2000 },
431 )
432 })
433 })
434
435 describe('other versions section', () => {
436 it('renders "Other versions" button', async () => {
437 const component = await mountSuspended(PackageVersions, {
438 props: {
439 packageName: 'test-package',
440 versions: {
441 '1.0.0': createVersion('1.0.0'),
442 },
443 distTags: { latest: '1.0.0' },
444 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
445 },
446 })
447
448 expect(component.text()).toContain('Other versions')
449 })
450
451 it('shows count of hidden tagged versions', async () => {
452 // Create more than MAX_VISIBLE_TAGS (10) dist-tags
453 const versions: Record<string, SlimVersion> = {}
454 const distTags: Record<string, string> = {}
455 const time: Record<string, string> = {}
456
457 for (let i = 0; i < 12; i++) {
458 const version = `1.${i}.0`
459 versions[version] = createVersion(version)
460 distTags[`tag-${i}`] = version
461 time[version] = `2024-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z`
462 }
463
464 const component = await mountSuspended(PackageVersions, {
465 props: {
466 packageName: 'test-package',
467 versions,
468 distTags,
469 time,
470 },
471 })
472
473 // Should show "(2 more tagged)" for the overflow
474 expect(component.text()).toContain('more tagged')
475 })
476
477 it('expands other versions section on click', async () => {
478 mockFetchAllPackageVersions.mockResolvedValue([
479 { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
480 { version: '0.5.0', time: '2024-01-01T12:00:00.000Z', hasProvenance: false },
481 ])
482
483 const component = await mountSuspended(PackageVersions, {
484 props: {
485 packageName: 'test-package',
486 versions: {
487 '1.0.0': createVersion('1.0.0'),
488 },
489 distTags: { latest: '1.0.0' },
490 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
491 },
492 })
493
494 // Find the "Other versions" button
495 const otherVersionsButton = component
496 .findAll('button')
497 .find(btn => btn.text().includes('Other versions'))
498
499 expect(otherVersionsButton?.exists()).toBe(true)
500 await otherVersionsButton!.trigger('click')
501
502 // Should call fetchAllPackageVersions
503 await vi.waitFor(() => {
504 expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package')
505 })
506 })
507
508 it('collapses other versions section when clicking again', async () => {
509 mockFetchAllPackageVersions.mockResolvedValue([
510 { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
511 ])
512
513 const component = await mountSuspended(PackageVersions, {
514 props: {
515 packageName: 'test-package',
516 versions: {
517 '1.0.0': createVersion('1.0.0'),
518 },
519 distTags: { latest: '1.0.0' },
520 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
521 },
522 })
523
524 const otherVersionsButton = component
525 .findAll('button')
526 .find(btn => btn.text().includes('Other versions'))
527
528 // Expand
529 await otherVersionsButton!.trigger('click')
530 await vi.waitFor(() => {
531 expect(otherVersionsButton!.attributes('aria-expanded')).toBe('true')
532 })
533
534 // Collapse
535 await otherVersionsButton!.trigger('click')
536 await vi.waitFor(() => {
537 expect(otherVersionsButton!.attributes('aria-expanded')).toBe('false')
538 })
539 })
540 })
541
542 describe('MAX_VISIBLE_TAGS limit', () => {
543 it('limits visible tag rows to 10', async () => {
544 // Create 15 dist-tags
545 const versions: Record<string, SlimVersion> = {}
546 const distTags: Record<string, string> = {}
547 const time: Record<string, string> = {}
548
549 for (let i = 0; i < 15; i++) {
550 const version = `${i + 1}.0.0`
551 versions[version] = createVersion(version)
552 distTags[`tag-${i}`] = version
553 time[version] = `2024-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z`
554 }
555
556 const component = await mountSuspended(PackageVersions, {
557 props: {
558 packageName: 'test-package',
559 versions,
560 distTags,
561 time,
562 },
563 })
564
565 // Count visible version links (excluding anchor links that start with # and external links)
566 const visibleLinks = component
567 .findAll('a')
568 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
569 // Should have max 10 visible links in the main section
570 expect(visibleLinks.length).toBeLessThanOrEqual(10)
571 })
572 })
573
574 describe('major version groups', () => {
575 it('groups unclaimed versions by major version in other versions', async () => {
576 mockFetchAllPackageVersions.mockResolvedValue([
577 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
578 { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
579 { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: false },
580 { version: '0.9.0', time: '2024-01-01T12:00:00.000Z', hasProvenance: false },
581 ])
582
583 const component = await mountSuspended(PackageVersions, {
584 props: {
585 packageName: 'test-package',
586 versions: {
587 '2.0.0': createVersion('2.0.0'),
588 },
589 distTags: { latest: '2.0.0' },
590 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
591 },
592 })
593
594 // Expand "Other versions"
595 const otherVersionsButton = component
596 .findAll('button')
597 .find(btn => btn.text().includes('Other versions'))
598
599 await otherVersionsButton!.trigger('click')
600
601 await vi.waitFor(() => {
602 // Major groups should be created for unclaimed versions
603 expect(mockFetchAllPackageVersions).toHaveBeenCalled()
604 })
605 })
606
607 it('allows expanding major version groups', async () => {
608 mockFetchAllPackageVersions.mockResolvedValue([
609 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
610 { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
611 { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: false },
612 ])
613
614 const component = await mountSuspended(PackageVersions, {
615 props: {
616 packageName: 'test-package',
617 versions: {
618 '2.0.0': createVersion('2.0.0'),
619 },
620 distTags: { latest: '2.0.0' },
621 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
622 },
623 })
624
625 // Expand "Other versions"
626 const otherVersionsButton = component
627 .findAll('button')
628 .find(btn => btn.text().includes('Other versions'))
629
630 await otherVersionsButton!.trigger('click')
631
632 // Wait for data to load and verify versions are shown
633 await vi.waitFor(() => {
634 const text = component.text()
635 expect(text.includes('1.1.0') || component.findAll('button').length > 2).toBe(true)
636 })
637 })
638
639 it('shows DateTime for major group versions', async () => {
640 mockFetchAllPackageVersions.mockResolvedValue([
641 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
642 { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
643 ])
644
645 const component = await mountSuspended(PackageVersions, {
646 props: {
647 packageName: 'test-package',
648 versions: {
649 '2.0.0': createVersion('2.0.0'),
650 },
651 distTags: { latest: '2.0.0' },
652 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
653 },
654 })
655
656 // Expand "Other versions"
657 const otherVersionsButton = component
658 .findAll('button')
659 .find(btn => btn.text().includes('Other versions'))
660
661 await otherVersionsButton!.trigger('click')
662
663 await vi.waitFor(() => {
664 // Should have DateTime components for both the main version and other versions
665 const dateTimeComponents = component.findAllComponents({ name: 'DateTime' })
666 expect(dateTimeComponents.length).toBeGreaterThan(1)
667 })
668 })
669
670 it('shows ProvenanceBadge for major group versions with provenance', async () => {
671 mockFetchAllPackageVersions.mockResolvedValue([
672 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: true },
673 { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: true },
674 ])
675
676 const component = await mountSuspended(PackageVersions, {
677 props: {
678 packageName: 'test-package',
679 versions: {
680 '2.0.0': createVersion('2.0.0', { hasProvenance: true }),
681 },
682 distTags: { latest: '2.0.0' },
683 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
684 },
685 })
686
687 // Expand "Other versions"
688 const otherVersionsButton = component
689 .findAll('button')
690 .find(btn => btn.text().includes('Other versions'))
691
692 await otherVersionsButton!.trigger('click')
693
694 await vi.waitFor(() => {
695 // Should have ProvenanceBadge components for versions with provenance
696 const provenanceBadges = component.findAllComponents({ name: 'ProvenanceBadge' })
697 expect(provenanceBadges.length).toBeGreaterThan(1)
698 })
699 })
700
701 it('renders major group header as clickable link', async () => {
702 mockFetchAllPackageVersions.mockResolvedValue([
703 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
704 { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
705 { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: false },
706 ])
707
708 const component = await mountSuspended(PackageVersions, {
709 props: {
710 packageName: 'test-package',
711 versions: {
712 '2.0.0': createVersion('2.0.0'),
713 },
714 distTags: { latest: '2.0.0' },
715 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
716 },
717 })
718
719 // Expand "Other versions"
720 const otherVersionsButton = component
721 .findAll('button')
722 .find(btn => btn.text().includes('Other versions'))
723
724 await otherVersionsButton!.trigger('click')
725
726 await vi.waitFor(() => {
727 // Find the major group header - should be a link (NuxtLink renders as <a>)
728 const links = component.findAll('a')
729 const majorGroupLink = links.find(l => l.text() === '1.1.0')
730 expect(majorGroupLink?.exists()).toBe(true)
731 })
732 })
733
734 it('shows DateTime and ProvenanceBadge for single version in major group', async () => {
735 mockFetchAllPackageVersions.mockResolvedValue([
736 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
737 { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: true },
738 ])
739
740 const component = await mountSuspended(PackageVersions, {
741 props: {
742 packageName: 'test-package',
743 versions: {
744 '2.0.0': createVersion('2.0.0'),
745 },
746 distTags: { latest: '2.0.0' },
747 time: { '2.0.0': '2024-01-15T12:00:00.000Z' },
748 },
749 })
750
751 // Expand "Other versions"
752 const otherVersionsButton = component
753 .findAll('button')
754 .find(btn => btn.text().includes('Other versions'))
755
756 await otherVersionsButton!.trigger('click')
757
758 await vi.waitFor(() => {
759 // Single version group (1.0.0) should still have DateTime
760 const dateTimeComponents = component.findAllComponents({ name: 'DateTime' })
761 expect(dateTimeComponents.length).toBeGreaterThan(1)
762
763 // And ProvenanceBadge for the version with provenance
764 const provenanceBadges = component.findAllComponents({ name: 'ProvenanceBadge' })
765 expect(provenanceBadges.length).toBeGreaterThan(0)
766 })
767 })
768 })
769
770 describe('loading states', () => {
771 it('shows loading spinner when fetching versions', async () => {
772 // Create a promise that won't resolve immediately
773 let resolvePromise: (value: unknown[]) => void
774 const loadingPromise = new Promise<unknown[]>(resolve => {
775 resolvePromise = resolve
776 })
777 mockFetchAllPackageVersions.mockReturnValue(loadingPromise)
778
779 const component = await mountSuspended(PackageVersions, {
780 props: {
781 packageName: 'test-package',
782 versions: {
783 '1.0.0': createVersion('1.0.0'),
784 },
785 distTags: { latest: '1.0.0' },
786 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
787 },
788 })
789
790 // Click expand
791 const expandButton = component.find('[data-testid="tag-expand-button"]')
792 await expandButton.trigger('click')
793
794 // Should show loading spinner (animate-spin class)
795 await vi.waitFor(() => {
796 expect(component.find('[data-testid="loading-spinner"]').exists()).toBe(true)
797 })
798
799 // Resolve the promise to clean up
800 resolvePromise!([])
801 })
802
803 it('shows loading spinner for other versions when fetching', async () => {
804 let resolvePromise: (value: unknown[]) => void
805 const loadingPromise = new Promise<unknown[]>(resolve => {
806 resolvePromise = resolve
807 })
808 mockFetchAllPackageVersions.mockReturnValue(loadingPromise)
809
810 const component = await mountSuspended(PackageVersions, {
811 props: {
812 packageName: 'test-package',
813 versions: {
814 '1.0.0': createVersion('1.0.0'),
815 },
816 distTags: { latest: '1.0.0' },
817 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
818 },
819 })
820
821 // Click "Other versions"
822 const otherVersionsButton = component
823 .findAll('button')
824 .find(btn => btn.text().includes('Other versions'))
825
826 await otherVersionsButton!.trigger('click')
827
828 // Should show loading spinner
829 await vi.waitFor(() => {
830 expect(component.find('[data-testid="loading-spinner"]').exists()).toBe(true)
831 })
832
833 // Resolve the promise to clean up
834 resolvePromise!([])
835 })
836 })
837
838 describe('accessibility', () => {
839 it('expand buttons have aria-expanded attribute', async () => {
840 const component = await mountSuspended(PackageVersions, {
841 props: {
842 packageName: 'test-package',
843 versions: {
844 '1.0.0': createVersion('1.0.0'),
845 },
846 distTags: { latest: '1.0.0' },
847 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
848 },
849 })
850
851 const expandButtons = component.findAll('button[aria-expanded]')
852 expect(expandButtons.length).toBeGreaterThan(0)
853 for (const button of expandButtons) {
854 expect(['true', 'false']).toContain(button.attributes('aria-expanded'))
855 }
856 })
857
858 it('expand buttons have aria-label attribute', async () => {
859 const component = await mountSuspended(PackageVersions, {
860 props: {
861 packageName: 'test-package',
862 versions: {
863 '1.0.0': createVersion('1.0.0'),
864 },
865 distTags: { latest: '1.0.0' },
866 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
867 },
868 })
869
870 const expandButton = component.find('button[aria-label]')
871 expect(expandButton.exists()).toBe(true)
872 expect(expandButton.attributes('aria-label')).toMatch(/Expand|Collapse/)
873 })
874
875 it('other versions button has aria-label', async () => {
876 const component = await mountSuspended(PackageVersions, {
877 props: {
878 packageName: 'test-package',
879 versions: {
880 '1.0.0': createVersion('1.0.0'),
881 },
882 distTags: { latest: '1.0.0' },
883 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
884 },
885 })
886
887 const otherVersionsButton = component
888 .findAll('button')
889 .find(btn => btn.text().includes('Other versions'))
890
891 expect(otherVersionsButton?.attributes('aria-label')).toMatch(/Expand other versions/)
892 })
893
894 it('expand buttons have visible focus states', async () => {
895 const component = await mountSuspended(PackageVersions, {
896 props: {
897 packageName: 'test-package',
898 versions: {
899 '1.0.0': createVersion('1.0.0'),
900 },
901 distTags: { latest: '1.0.0' },
902 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
903 },
904 })
905
906 const expandButton = component.find('button[aria-expanded]')
907 expect(expandButton.classes().some(c => c.includes('focus-visible'))).toBe(true)
908 })
909
910 it('icons have aria-hidden attribute', async () => {
911 const component = await mountSuspended(PackageVersions, {
912 props: {
913 packageName: 'test-package',
914 versions: {
915 '1.0.0': createVersion('1.0.0'),
916 },
917 distTags: { latest: '1.0.0' },
918 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
919 },
920 })
921
922 // Find chevron icons inside buttons
923 const chevronIcons = component.findAll('button span.i-lucide\\:chevron-right')
924 expect(chevronIcons.length).toBeGreaterThan(0)
925 for (const icon of chevronIcons) {
926 expect(icon.attributes('aria-hidden')).toBe('true')
927 }
928 })
929 })
930
931 describe('semver range filter', () => {
932 const multiVersionProps = {
933 packageName: 'test-package',
934 versions: {
935 '3.0.0': createVersion('3.0.0'),
936 '2.1.0': createVersion('2.1.0'),
937 '2.0.0': createVersion('2.0.0'),
938 '1.0.0': createVersion('1.0.0'),
939 },
940 distTags: {
941 latest: '3.0.0',
942 stable: '2.1.0',
943 legacy: '1.0.0',
944 },
945 time: {
946 '3.0.0': '2024-04-01T00:00:00.000Z',
947 '2.1.0': '2024-03-01T00:00:00.000Z',
948 '2.0.0': '2024-02-01T00:00:00.000Z',
949 '1.0.0': '2024-01-01T00:00:00.000Z',
950 },
951 }
952
953 it('renders the filter input', async () => {
954 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
955
956 const input = component.find('input[type="text"]')
957 expect(input.exists()).toBe(true)
958 expect(input.attributes('placeholder')).toContain('semver')
959 })
960
961 it('filters visible tag rows by semver range', async () => {
962 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
963
964 const input = component.find('input[type="text"]')
965 await input.setValue('^2.0.0')
966
967 // 2.1.0 matches ^2.0.0, so the "stable" tag row should be visible
968 const text = component.text()
969 expect(text).toContain('2.1.0')
970 // 3.0.0 does NOT match ^2.0.0
971 // Find version links (exclude anchor and external links)
972 const versionLinks = component
973 .findAll('a')
974 .filter(a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank')
975 const versions = versionLinks.map(l => l.text())
976 expect(versions).not.toContain('3.0.0')
977 })
978
979 it('shows "no matches" message when no versions match', async () => {
980 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
981
982 const input = component.find('input[type="text"]')
983 await input.setValue('^99.0.0')
984
985 expect(component.text()).toContain('No versions match this range')
986 })
987
988 it('no matches message has aria-live for screen readers', async () => {
989 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
990
991 const input = component.find('input[type="text"]')
992 await input.setValue('^99.0.0')
993
994 const noMatchesEl = component.find('[role="status"]')
995 expect(noMatchesEl.exists()).toBe(true)
996 expect(noMatchesEl.attributes('aria-live')).toBe('polite')
997 })
998
999 it('shows all versions when filter is cleared', async () => {
1000 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1001
1002 const input = component.find('input[type="text"]')
1003 await input.setValue('^2.0.0')
1004 await input.setValue('')
1005
1006 // All tag rows should be visible again
1007 const text = component.text()
1008 expect(text).toContain('3.0.0')
1009 expect(text).toContain('2.1.0')
1010 expect(text).toContain('1.0.0')
1011 })
1012
1013 it('shows invalid range indicator for bad input', async () => {
1014 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1015
1016 const input = component.find('input[type="text"]')
1017 await input.setValue('not-a-range!!!')
1018
1019 // Error message should appear
1020 const errorEl = component.find('#semver-filter-error')
1021 expect(errorEl.exists()).toBe(true)
1022 expect(errorEl.attributes('role')).toBe('alert')
1023
1024 // Input should be marked invalid
1025 expect(input.attributes('aria-invalid')).toBe('true')
1026 expect(input.attributes('aria-describedby')).toBe('semver-filter-error')
1027 })
1028
1029 it('does not show invalid range indicator for valid input', async () => {
1030 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1031
1032 const input = component.find('input[type="text"]')
1033 await input.setValue('^2.0.0')
1034
1035 expect(component.find('#semver-filter-error').exists()).toBe(false)
1036 expect(input.attributes('aria-invalid')).toBeUndefined()
1037 })
1038
1039 it('does not show invalid range indicator when input is empty', async () => {
1040 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1041
1042 const input = component.find('input[type="text"]')
1043 await input.setValue('')
1044
1045 expect(component.find('#semver-filter-error').exists()).toBe(false)
1046 })
1047
1048 it('filters expanded tag child versions', async () => {
1049 mockFetchAllPackageVersions.mockResolvedValue([
1050 { version: '3.0.0', time: '2024-04-01T00:00:00.000Z', hasProvenance: false },
1051 { version: '2.1.0', time: '2024-03-01T00:00:00.000Z', hasProvenance: false },
1052 { version: '2.0.0', time: '2024-02-01T00:00:00.000Z', hasProvenance: false },
1053 { version: '1.0.0', time: '2024-01-01T00:00:00.000Z', hasProvenance: false },
1054 ])
1055
1056 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1057
1058 // Expand the "stable" tag (2.1.0)
1059 const expandButtons = component.findAll('[data-testid="tag-expand-button"]')
1060 const stableButton = expandButtons.find(btn =>
1061 btn.attributes('aria-label')?.includes('stable'),
1062 )
1063 expect(stableButton?.exists()).toBe(true)
1064 await stableButton!.trigger('click')
1065 await vi.waitFor(() => {
1066 expect(mockFetchAllPackageVersions).toHaveBeenCalled()
1067 })
1068
1069 // Now filter to only 2.1.x
1070 const input = component.find('input[type="text"]')
1071 await input.setValue('~2.1.0')
1072
1073 // 2.0.0 should not appear in the expanded list
1074 await vi.waitFor(() => {
1075 const versionLinks = component
1076 .findAll('a')
1077 .filter(
1078 a => !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank',
1079 )
1080 const versions = versionLinks.map(l => l.text())
1081 expect(versions).not.toContain('2.0.0')
1082 })
1083 })
1084
1085 it('filters other major version groups', async () => {
1086 mockFetchAllPackageVersions.mockResolvedValue([
1087 { version: '3.0.0', time: '2024-04-01T00:00:00.000Z', hasProvenance: false },
1088 { version: '2.1.0', time: '2024-03-01T00:00:00.000Z', hasProvenance: false },
1089 { version: '2.0.0', time: '2024-02-01T00:00:00.000Z', hasProvenance: false },
1090 { version: '1.0.0', time: '2024-01-01T00:00:00.000Z', hasProvenance: false },
1091 { version: '0.5.0', time: '2023-06-01T00:00:00.000Z', hasProvenance: false },
1092 ])
1093
1094 const component = await mountSuspended(PackageVersions, { props: multiVersionProps })
1095
1096 // Expand "Other versions"
1097 const otherVersionsButton = component
1098 .findAll('button')
1099 .find(btn => btn.text().includes('Other versions'))
1100 await otherVersionsButton!.trigger('click')
1101
1102 await vi.waitFor(() => {
1103 expect(mockFetchAllPackageVersions).toHaveBeenCalled()
1104 })
1105
1106 // Filter to >=2.0.0
1107 const input = component.find('input[type="text"]')
1108 await input.setValue('>=2.0.0')
1109
1110 // 0.5.0 should not appear
1111 await vi.waitFor(() => {
1112 const text = component.text()
1113 expect(text).not.toContain('0.5.0')
1114 })
1115 })
1116 })
1117
1118 describe('error handling', () => {
1119 it('handles fetch errors gracefully', async () => {
1120 mockFetchAllPackageVersions.mockRejectedValue(new Error('Network error'))
1121
1122 const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1123
1124 const component = await mountSuspended(PackageVersions, {
1125 props: {
1126 packageName: 'test-package',
1127 versions: {
1128 '1.0.0': createVersion('1.0.0'),
1129 },
1130 distTags: { latest: '1.0.0' },
1131 time: { '1.0.0': '2024-01-15T12:00:00.000Z' },
1132 },
1133 })
1134
1135 // Click expand
1136 const expandButton = component.find('[data-testid="tag-expand-button"]')
1137 await expandButton.trigger('click')
1138
1139 // Wait for error to be logged
1140 await vi.waitFor(() => {
1141 expect(consoleSpy).toHaveBeenCalledWith('Failed to load versions:', expect.any(Error))
1142 })
1143
1144 consoleSpy.mockRestore()
1145 })
1146 })
1147
1148 describe('caching behavior', () => {
1149 it('only fetches versions once when expanding multiple tags', async () => {
1150 mockFetchAllPackageVersions.mockResolvedValue([
1151 { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
1152 { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
1153 ])
1154
1155 const component = await mountSuspended(PackageVersions, {
1156 props: {
1157 packageName: 'test-package',
1158 versions: {
1159 '2.0.0': createVersion('2.0.0'),
1160 '1.0.0': createVersion('1.0.0'),
1161 },
1162 distTags: {
1163 latest: '2.0.0',
1164 stable: '1.0.0',
1165 },
1166 time: {
1167 '2.0.0': '2024-01-15T12:00:00.000Z',
1168 '1.0.0': '2024-01-10T12:00:00.000Z',
1169 },
1170 },
1171 })
1172
1173 // Expand first tag row
1174 const expandButtons = component.findAll('[data-testid="tag-expand-button"]')
1175 await expandButtons[0]?.trigger('click')
1176
1177 await vi.waitFor(() => {
1178 expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1)
1179 })
1180
1181 // Expand second tag row - should not fetch again
1182 const updatedButtons = component.findAll('[data-testid="tag-expand-button"]')
1183 if (updatedButtons[1]) {
1184 await updatedButtons[1].trigger('click')
1185 }
1186
1187 // Should still only have been called once
1188 expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1)
1189 })
1190 })
1191})