[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1191 lines 41 kB view raw
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})