[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.

test: add component tests for `<PackageVersions>`

related to https://github.com/npmx-dev/npmx.dev/pull/156

+824
+824
test/nuxt/components/PackageVersions.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import PackageVersions from '~/components/PackageVersions.vue' 4 + import type { PackumentVersion } from '#shared/types' 5 + 6 + // Mock the fetchAllPackageVersions function 7 + const mockFetchAllPackageVersions = vi.fn() 8 + vi.mock('~/composables/useNpmRegistry', () => ({ 9 + fetchAllPackageVersions: (...args: unknown[]) => mockFetchAllPackageVersions(...args), 10 + })) 11 + 12 + /** 13 + * Helper to create a minimal PackumentVersion for testing 14 + */ 15 + function createVersion( 16 + version: string, 17 + options: { 18 + deprecated?: string 19 + hasProvenance?: boolean 20 + } = {}, 21 + ): PackumentVersion { 22 + const dist: Record<string, unknown> = { 23 + tarball: `https://registry.npmjs.org/test-package/-/test-package-${version}.tgz`, 24 + shasum: 'abc123', 25 + } 26 + if (options.hasProvenance) { 27 + dist.attestations = { url: 'https://example.com', provenance: { predicateType: 'test' } } 28 + } 29 + return { 30 + _id: `test-package@${version}`, 31 + _npmVersion: '10.0.0', 32 + name: 'test-package', 33 + version, 34 + dist, 35 + deprecated: options.deprecated, 36 + } as unknown as PackumentVersion 37 + } 38 + 39 + describe('PackageVersions', () => { 40 + beforeEach(() => { 41 + mockFetchAllPackageVersions.mockReset() 42 + }) 43 + 44 + describe('basic rendering', () => { 45 + it('renders the Versions heading', async () => { 46 + const component = await mountSuspended(PackageVersions, { 47 + props: { 48 + packageName: 'test-package', 49 + versions: { 50 + '1.0.0': createVersion('1.0.0'), 51 + }, 52 + distTags: { latest: '1.0.0' }, 53 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 54 + }, 55 + }) 56 + 57 + expect(component.find('#versions-heading').text()).toBe('Versions') 58 + }) 59 + 60 + it('does not render when there are no dist-tags', async () => { 61 + const component = await mountSuspended(PackageVersions, { 62 + props: { 63 + packageName: 'test-package', 64 + versions: {}, 65 + distTags: {}, 66 + time: {}, 67 + }, 68 + }) 69 + 70 + expect(component.find('section').exists()).toBe(false) 71 + }) 72 + 73 + it('renders version links with correct routes', async () => { 74 + const component = await mountSuspended(PackageVersions, { 75 + props: { 76 + packageName: 'test-package', 77 + versions: { 78 + '2.0.0': createVersion('2.0.0'), 79 + }, 80 + distTags: { latest: '2.0.0' }, 81 + time: { '2.0.0': '2024-01-15T12:00:00.000Z' }, 82 + }, 83 + }) 84 + 85 + const link = component.find('a') 86 + expect(link.exists()).toBe(true) 87 + expect(link.text()).toBe('2.0.0') 88 + }) 89 + 90 + it('renders scoped package version links correctly', async () => { 91 + const component = await mountSuspended(PackageVersions, { 92 + props: { 93 + packageName: '@scope/test-package', 94 + versions: { 95 + '1.0.0': createVersion('1.0.0'), 96 + }, 97 + distTags: { latest: '1.0.0' }, 98 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 99 + }, 100 + }) 101 + 102 + const link = component.find('a') 103 + expect(link.exists()).toBe(true) 104 + expect(link.text()).toBe('1.0.0') 105 + }) 106 + }) 107 + 108 + describe('dist-tag display', () => { 109 + it('displays dist-tag labels below version', async () => { 110 + const component = await mountSuspended(PackageVersions, { 111 + props: { 112 + packageName: 'test-package', 113 + versions: { 114 + '1.0.0': createVersion('1.0.0'), 115 + }, 116 + distTags: { latest: '1.0.0' }, 117 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 118 + }, 119 + }) 120 + 121 + expect(component.text()).toContain('latest') 122 + }) 123 + 124 + it('displays multiple tags for same version', async () => { 125 + const component = await mountSuspended(PackageVersions, { 126 + props: { 127 + packageName: 'test-package', 128 + versions: { 129 + '1.0.0': createVersion('1.0.0'), 130 + }, 131 + distTags: { 132 + latest: '1.0.0', 133 + stable: '1.0.0', 134 + }, 135 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 136 + }, 137 + }) 138 + 139 + const text = component.text() 140 + expect(text).toContain('latest') 141 + expect(text).toContain('stable') 142 + }) 143 + 144 + it('shows "latest" tag first when multiple tags exist', async () => { 145 + const component = await mountSuspended(PackageVersions, { 146 + props: { 147 + packageName: 'test-package', 148 + versions: { 149 + '1.0.0': createVersion('1.0.0'), 150 + }, 151 + distTags: { 152 + alpha: '1.0.0', 153 + latest: '1.0.0', 154 + beta: '1.0.0', 155 + }, 156 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 157 + }, 158 + }) 159 + 160 + // Find all tag spans 161 + const tagSpans = component.findAll('span').filter(span => { 162 + const text = span.text() 163 + return text === 'latest' || text === 'alpha' || text === 'beta' 164 + }) 165 + 166 + expect(tagSpans.length).toBeGreaterThanOrEqual(3) 167 + // latest should be first 168 + expect(tagSpans[0]?.text()).toBe('latest') 169 + }) 170 + 171 + it('sorts tag rows by version descending', async () => { 172 + const component = await mountSuspended(PackageVersions, { 173 + props: { 174 + packageName: 'test-package', 175 + versions: { 176 + '1.0.0': createVersion('1.0.0'), 177 + '2.0.0': createVersion('2.0.0'), 178 + '1.5.0': createVersion('1.5.0'), 179 + }, 180 + distTags: { 181 + old: '1.0.0', 182 + latest: '2.0.0', 183 + stable: '1.5.0', 184 + }, 185 + time: { 186 + '1.0.0': '2024-01-01T00:00:00.000Z', 187 + '1.5.0': '2024-01-10T00:00:00.000Z', 188 + '2.0.0': '2024-01-15T00:00:00.000Z', 189 + }, 190 + }, 191 + }) 192 + 193 + const links = component.findAll('a') 194 + const versions = links.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 + const link = component.find('a') 214 + expect(link.classes()).toContain('text-red-400') 215 + }) 216 + 217 + it('shows deprecated version in title attribute', async () => { 218 + const component = await mountSuspended(PackageVersions, { 219 + props: { 220 + packageName: 'test-package', 221 + versions: { 222 + '1.0.0': createVersion('1.0.0', { deprecated: 'Use 2.0.0 instead' }), 223 + }, 224 + distTags: { latest: '1.0.0' }, 225 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 226 + }, 227 + }) 228 + 229 + const link = component.find('a') 230 + expect(link.attributes('title')).toContain('deprecated') 231 + }) 232 + 233 + it('filters deprecated tags from visible list when package is not deprecated', async () => { 234 + const component = await mountSuspended(PackageVersions, { 235 + props: { 236 + packageName: 'test-package', 237 + versions: { 238 + '1.0.0': createVersion('1.0.0', { deprecated: 'Old version' }), 239 + '2.0.0': createVersion('2.0.0'), 240 + }, 241 + distTags: { 242 + old: '1.0.0', 243 + latest: '2.0.0', 244 + }, 245 + time: { 246 + '1.0.0': '2024-01-01T00:00:00.000Z', 247 + '2.0.0': '2024-01-15T00:00:00.000Z', 248 + }, 249 + }, 250 + }) 251 + 252 + // The deprecated version should not appear in visible tags (only in "Other versions") 253 + const visibleLinks = component.findAll('a').filter(l => l.text() === '1.0.0') 254 + expect(visibleLinks.length).toBe(0) 255 + }) 256 + }) 257 + 258 + describe('provenance badge', () => { 259 + it('shows provenance badge for versions with attestations', async () => { 260 + const component = await mountSuspended(PackageVersions, { 261 + props: { 262 + packageName: 'test-package', 263 + versions: { 264 + '1.0.0': createVersion('1.0.0', { hasProvenance: true }), 265 + }, 266 + distTags: { latest: '1.0.0' }, 267 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 268 + }, 269 + }) 270 + 271 + // ProvenanceBadge component should be rendered 272 + const provenanceBadge = component.findComponent({ name: 'ProvenanceBadge' }) 273 + expect(provenanceBadge.exists()).toBe(true) 274 + }) 275 + 276 + it('does not show provenance badge for versions without attestations', async () => { 277 + const component = await mountSuspended(PackageVersions, { 278 + props: { 279 + packageName: 'test-package', 280 + versions: { 281 + '1.0.0': createVersion('1.0.0', { hasProvenance: false }), 282 + }, 283 + distTags: { latest: '1.0.0' }, 284 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 285 + }, 286 + }) 287 + 288 + const provenanceBadge = component.findComponent({ name: 'ProvenanceBadge' }) 289 + expect(provenanceBadge.exists()).toBe(false) 290 + }) 291 + }) 292 + 293 + describe('datetime display', () => { 294 + it('shows DateTime component for versions with time', async () => { 295 + const component = await mountSuspended(PackageVersions, { 296 + props: { 297 + packageName: 'test-package', 298 + versions: { 299 + '1.0.0': createVersion('1.0.0'), 300 + }, 301 + distTags: { latest: '1.0.0' }, 302 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 303 + }, 304 + }) 305 + 306 + const dateTime = component.findComponent({ name: 'DateTime' }) 307 + expect(dateTime.exists()).toBe(true) 308 + }) 309 + }) 310 + 311 + describe('expand/collapse tag rows', () => { 312 + it('shows expand button for tag rows', async () => { 313 + const component = await mountSuspended(PackageVersions, { 314 + props: { 315 + packageName: 'test-package', 316 + versions: { 317 + '1.0.0': createVersion('1.0.0'), 318 + }, 319 + distTags: { latest: '1.0.0' }, 320 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 321 + }, 322 + }) 323 + 324 + const expandButton = component.find('button[aria-expanded]') 325 + expect(expandButton.exists()).toBe(true) 326 + expect(expandButton.attributes('aria-expanded')).toBe('false') 327 + }) 328 + 329 + it('has correct aria-label on expand button', async () => { 330 + const component = await mountSuspended(PackageVersions, { 331 + props: { 332 + packageName: 'test-package', 333 + versions: { 334 + '1.0.0': createVersion('1.0.0'), 335 + }, 336 + distTags: { latest: '1.0.0' }, 337 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 338 + }, 339 + }) 340 + 341 + const expandButton = component.find('button[aria-expanded="false"]') 342 + expect(expandButton.attributes('aria-label')).toBe('Expand latest') 343 + }) 344 + 345 + it('loads versions and toggles expanded state on click', async () => { 346 + mockFetchAllPackageVersions.mockResolvedValue([ 347 + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 348 + { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, 349 + ]) 350 + 351 + const component = await mountSuspended(PackageVersions, { 352 + props: { 353 + packageName: 'test-package', 354 + versions: { 355 + '1.0.0': createVersion('1.0.0'), 356 + }, 357 + distTags: { latest: '1.0.0' }, 358 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 359 + }, 360 + }) 361 + 362 + const expandButton = component.find('button[aria-expanded="false"]') 363 + await expandButton.trigger('click') 364 + 365 + // Wait for async operation 366 + await vi.waitFor(() => { 367 + expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package') 368 + }) 369 + }) 370 + 371 + it('collapses when clicking expanded row', async () => { 372 + // Return multiple versions so the expand button stays visible after loading 373 + mockFetchAllPackageVersions.mockResolvedValue([ 374 + { version: '1.2.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 375 + { version: '1.1.0', time: '2024-01-12T12:00:00.000Z', hasProvenance: false }, 376 + { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, 377 + ]) 378 + 379 + const component = await mountSuspended(PackageVersions, { 380 + props: { 381 + packageName: 'test-package', 382 + versions: { 383 + '1.2.0': createVersion('1.2.0'), 384 + }, 385 + distTags: { latest: '1.2.0' }, 386 + time: { '1.2.0': '2024-01-15T12:00:00.000Z' }, 387 + }, 388 + }) 389 + 390 + // Get initial expand button 391 + const expandButton = component.find('button[aria-expanded]') 392 + expect(expandButton.exists()).toBe(true) 393 + 394 + // Expand 395 + await expandButton.trigger('click') 396 + 397 + // Wait for versions to load and expansion to happen 398 + await vi.waitFor(() => { 399 + expect(mockFetchAllPackageVersions).toHaveBeenCalled() 400 + }) 401 + 402 + // Wait for the component to update after loading 403 + await vi.waitFor( 404 + () => { 405 + const btn = component.find('button[aria-expanded="true"]') 406 + expect(btn.exists()).toBe(true) 407 + }, 408 + { timeout: 2000 }, 409 + ) 410 + 411 + // Now collapse by clicking again 412 + const expandedButton = component.find('button[aria-expanded="true"]') 413 + await expandedButton.trigger('click') 414 + 415 + await vi.waitFor( 416 + () => { 417 + const btn = component.find('button[aria-expanded="false"]') 418 + expect(btn.exists()).toBe(true) 419 + }, 420 + { timeout: 2000 }, 421 + ) 422 + }) 423 + }) 424 + 425 + describe('other versions section', () => { 426 + it('renders "Other versions" button', async () => { 427 + const component = await mountSuspended(PackageVersions, { 428 + props: { 429 + packageName: 'test-package', 430 + versions: { 431 + '1.0.0': createVersion('1.0.0'), 432 + }, 433 + distTags: { latest: '1.0.0' }, 434 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 435 + }, 436 + }) 437 + 438 + expect(component.text()).toContain('Other versions') 439 + }) 440 + 441 + it('shows count of hidden tagged versions', async () => { 442 + // Create more than MAX_VISIBLE_TAGS (10) dist-tags 443 + const versions: Record<string, PackumentVersion> = {} 444 + const distTags: Record<string, string> = {} 445 + const time: Record<string, string> = {} 446 + 447 + for (let i = 0; i < 12; i++) { 448 + const version = `1.${i}.0` 449 + versions[version] = createVersion(version) 450 + distTags[`tag-${i}`] = version 451 + time[version] = `2024-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z` 452 + } 453 + 454 + const component = await mountSuspended(PackageVersions, { 455 + props: { 456 + packageName: 'test-package', 457 + versions, 458 + distTags, 459 + time, 460 + }, 461 + }) 462 + 463 + // Should show "(2 more tagged)" for the overflow 464 + expect(component.text()).toContain('more tagged') 465 + }) 466 + 467 + it('expands other versions section on click', async () => { 468 + mockFetchAllPackageVersions.mockResolvedValue([ 469 + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 470 + { version: '0.5.0', time: '2024-01-01T12:00:00.000Z', hasProvenance: false }, 471 + ]) 472 + 473 + const component = await mountSuspended(PackageVersions, { 474 + props: { 475 + packageName: 'test-package', 476 + versions: { 477 + '1.0.0': createVersion('1.0.0'), 478 + }, 479 + distTags: { latest: '1.0.0' }, 480 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 481 + }, 482 + }) 483 + 484 + // Find the "Other versions" button 485 + const otherVersionsButton = component 486 + .findAll('button') 487 + .find(btn => btn.text().includes('Other versions')) 488 + 489 + expect(otherVersionsButton?.exists()).toBe(true) 490 + await otherVersionsButton!.trigger('click') 491 + 492 + // Should call fetchAllPackageVersions 493 + await vi.waitFor(() => { 494 + expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package') 495 + }) 496 + }) 497 + 498 + it('collapses other versions section when clicking again', async () => { 499 + mockFetchAllPackageVersions.mockResolvedValue([ 500 + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 501 + ]) 502 + 503 + const component = await mountSuspended(PackageVersions, { 504 + props: { 505 + packageName: 'test-package', 506 + versions: { 507 + '1.0.0': createVersion('1.0.0'), 508 + }, 509 + distTags: { latest: '1.0.0' }, 510 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 511 + }, 512 + }) 513 + 514 + const otherVersionsButton = component 515 + .findAll('button') 516 + .find(btn => btn.text().includes('Other versions')) 517 + 518 + // Expand 519 + await otherVersionsButton!.trigger('click') 520 + await vi.waitFor(() => { 521 + expect(otherVersionsButton!.attributes('aria-expanded')).toBe('true') 522 + }) 523 + 524 + // Collapse 525 + await otherVersionsButton!.trigger('click') 526 + await vi.waitFor(() => { 527 + expect(otherVersionsButton!.attributes('aria-expanded')).toBe('false') 528 + }) 529 + }) 530 + }) 531 + 532 + describe('MAX_VISIBLE_TAGS limit', () => { 533 + it('limits visible tag rows to 10', async () => { 534 + // Create 15 dist-tags 535 + const versions: Record<string, PackumentVersion> = {} 536 + const distTags: Record<string, string> = {} 537 + const time: Record<string, string> = {} 538 + 539 + for (let i = 0; i < 15; i++) { 540 + const version = `${i + 1}.0.0` 541 + versions[version] = createVersion(version) 542 + distTags[`tag-${i}`] = version 543 + time[version] = `2024-01-${String(i + 1).padStart(2, '0')}T12:00:00.000Z` 544 + } 545 + 546 + const component = await mountSuspended(PackageVersions, { 547 + props: { 548 + packageName: 'test-package', 549 + versions, 550 + distTags, 551 + time, 552 + }, 553 + }) 554 + 555 + // Count visible version links (excluding "Other versions" section) 556 + // The first set of links before the "Other versions" button 557 + const visibleLinks = component.findAll('a') 558 + // Should have max 10 visible links in the main section 559 + expect(visibleLinks.length).toBeLessThanOrEqual(10) 560 + }) 561 + }) 562 + 563 + describe('major version groups', () => { 564 + it('groups unclaimed versions by major version in other versions', async () => { 565 + mockFetchAllPackageVersions.mockResolvedValue([ 566 + { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 567 + { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, 568 + { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: false }, 569 + { version: '0.9.0', time: '2024-01-01T12:00:00.000Z', hasProvenance: false }, 570 + ]) 571 + 572 + const component = await mountSuspended(PackageVersions, { 573 + props: { 574 + packageName: 'test-package', 575 + versions: { 576 + '2.0.0': createVersion('2.0.0'), 577 + }, 578 + distTags: { latest: '2.0.0' }, 579 + time: { '2.0.0': '2024-01-15T12:00:00.000Z' }, 580 + }, 581 + }) 582 + 583 + // Expand "Other versions" 584 + const otherVersionsButton = component 585 + .findAll('button') 586 + .find(btn => btn.text().includes('Other versions')) 587 + 588 + await otherVersionsButton!.trigger('click') 589 + 590 + await vi.waitFor(() => { 591 + // Major groups should be created for unclaimed versions 592 + expect(mockFetchAllPackageVersions).toHaveBeenCalled() 593 + }) 594 + }) 595 + 596 + it('allows expanding major version groups', async () => { 597 + mockFetchAllPackageVersions.mockResolvedValue([ 598 + { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 599 + { version: '1.1.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, 600 + { version: '1.0.0', time: '2024-01-05T12:00:00.000Z', hasProvenance: false }, 601 + ]) 602 + 603 + const component = await mountSuspended(PackageVersions, { 604 + props: { 605 + packageName: 'test-package', 606 + versions: { 607 + '2.0.0': createVersion('2.0.0'), 608 + }, 609 + distTags: { latest: '2.0.0' }, 610 + time: { '2.0.0': '2024-01-15T12:00:00.000Z' }, 611 + }, 612 + }) 613 + 614 + // Expand "Other versions" 615 + const otherVersionsButton = component 616 + .findAll('button') 617 + .find(btn => btn.text().includes('Other versions')) 618 + 619 + await otherVersionsButton!.trigger('click') 620 + 621 + // Wait for data to load and verify versions are shown 622 + await vi.waitFor(() => { 623 + const text = component.text() 624 + expect(text.includes('1.1.0') || component.findAll('button').length > 2).toBe(true) 625 + }) 626 + }) 627 + }) 628 + 629 + describe('loading states', () => { 630 + it('shows loading spinner when fetching versions', async () => { 631 + // Create a promise that won't resolve immediately 632 + let resolvePromise: (value: unknown[]) => void 633 + const loadingPromise = new Promise<unknown[]>(resolve => { 634 + resolvePromise = resolve 635 + }) 636 + mockFetchAllPackageVersions.mockReturnValue(loadingPromise) 637 + 638 + const component = await mountSuspended(PackageVersions, { 639 + props: { 640 + packageName: 'test-package', 641 + versions: { 642 + '1.0.0': createVersion('1.0.0'), 643 + }, 644 + distTags: { latest: '1.0.0' }, 645 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 646 + }, 647 + }) 648 + 649 + // Click expand 650 + const expandButton = component.find('button[aria-expanded]') 651 + await expandButton.trigger('click') 652 + 653 + // Should show loading spinner (animate-spin class) 654 + await vi.waitFor(() => { 655 + expect(component.find('.animate-spin').exists()).toBe(true) 656 + }) 657 + 658 + // Resolve the promise to clean up 659 + resolvePromise!([]) 660 + }) 661 + 662 + it('shows loading spinner for other versions when fetching', async () => { 663 + let resolvePromise: (value: unknown[]) => void 664 + const loadingPromise = new Promise<unknown[]>(resolve => { 665 + resolvePromise = resolve 666 + }) 667 + mockFetchAllPackageVersions.mockReturnValue(loadingPromise) 668 + 669 + const component = await mountSuspended(PackageVersions, { 670 + props: { 671 + packageName: 'test-package', 672 + versions: { 673 + '1.0.0': createVersion('1.0.0'), 674 + }, 675 + distTags: { latest: '1.0.0' }, 676 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 677 + }, 678 + }) 679 + 680 + // Click "Other versions" 681 + const otherVersionsButton = component 682 + .findAll('button') 683 + .find(btn => btn.text().includes('Other versions')) 684 + 685 + await otherVersionsButton!.trigger('click') 686 + 687 + // Should show loading spinner 688 + await vi.waitFor(() => { 689 + expect(component.find('.animate-spin').exists()).toBe(true) 690 + }) 691 + 692 + // Resolve the promise to clean up 693 + resolvePromise!([]) 694 + }) 695 + }) 696 + 697 + describe('accessibility', () => { 698 + it('has accessible section with labelledby', async () => { 699 + const component = await mountSuspended(PackageVersions, { 700 + props: { 701 + packageName: 'test-package', 702 + versions: { 703 + '1.0.0': createVersion('1.0.0'), 704 + }, 705 + distTags: { latest: '1.0.0' }, 706 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 707 + }, 708 + }) 709 + 710 + const section = component.find('section') 711 + expect(section.attributes('aria-labelledby')).toBe('versions-heading') 712 + }) 713 + 714 + it('expand buttons have aria-expanded attribute', async () => { 715 + const component = await mountSuspended(PackageVersions, { 716 + props: { 717 + packageName: 'test-package', 718 + versions: { 719 + '1.0.0': createVersion('1.0.0'), 720 + }, 721 + distTags: { latest: '1.0.0' }, 722 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 723 + }, 724 + }) 725 + 726 + const expandButtons = component.findAll('button[aria-expanded]') 727 + expect(expandButtons.length).toBeGreaterThan(0) 728 + for (const button of expandButtons) { 729 + expect(['true', 'false']).toContain(button.attributes('aria-expanded')) 730 + } 731 + }) 732 + 733 + it('expand buttons have aria-label attribute', async () => { 734 + const component = await mountSuspended(PackageVersions, { 735 + props: { 736 + packageName: 'test-package', 737 + versions: { 738 + '1.0.0': createVersion('1.0.0'), 739 + }, 740 + distTags: { latest: '1.0.0' }, 741 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 742 + }, 743 + }) 744 + 745 + const expandButton = component.find('button[aria-label]') 746 + expect(expandButton.exists()).toBe(true) 747 + expect(expandButton.attributes('aria-label')).toMatch(/Expand|Collapse/) 748 + }) 749 + }) 750 + 751 + describe('error handling', () => { 752 + it('handles fetch errors gracefully', async () => { 753 + mockFetchAllPackageVersions.mockRejectedValue(new Error('Network error')) 754 + 755 + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 756 + 757 + const component = await mountSuspended(PackageVersions, { 758 + props: { 759 + packageName: 'test-package', 760 + versions: { 761 + '1.0.0': createVersion('1.0.0'), 762 + }, 763 + distTags: { latest: '1.0.0' }, 764 + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, 765 + }, 766 + }) 767 + 768 + // Click expand 769 + const expandButton = component.find('button[aria-expanded]') 770 + await expandButton.trigger('click') 771 + 772 + // Wait for error to be logged 773 + await vi.waitFor(() => { 774 + expect(consoleSpy).toHaveBeenCalledWith('Failed to load versions:', expect.any(Error)) 775 + }) 776 + 777 + consoleSpy.mockRestore() 778 + }) 779 + }) 780 + 781 + describe('caching behavior', () => { 782 + it('only fetches versions once when expanding multiple tags', async () => { 783 + mockFetchAllPackageVersions.mockResolvedValue([ 784 + { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, 785 + { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, 786 + ]) 787 + 788 + const component = await mountSuspended(PackageVersions, { 789 + props: { 790 + packageName: 'test-package', 791 + versions: { 792 + '2.0.0': createVersion('2.0.0'), 793 + '1.0.0': createVersion('1.0.0'), 794 + }, 795 + distTags: { 796 + latest: '2.0.0', 797 + stable: '1.0.0', 798 + }, 799 + time: { 800 + '2.0.0': '2024-01-15T12:00:00.000Z', 801 + '1.0.0': '2024-01-10T12:00:00.000Z', 802 + }, 803 + }, 804 + }) 805 + 806 + // Expand first tag row 807 + const expandButtons = component.findAll('button[aria-expanded="false"]') 808 + await expandButtons[0]?.trigger('click') 809 + 810 + await vi.waitFor(() => { 811 + expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1) 812 + }) 813 + 814 + // Expand second tag row - should not fetch again 815 + const updatedButtons = component.findAll('button[aria-expanded="false"]') 816 + if (updatedButtons[0]) { 817 + await updatedButtons[0].trigger('click') 818 + } 819 + 820 + // Should still only have been called once 821 + expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1) 822 + }) 823 + }) 824 + })