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

feat(ui): add input to filter package versions by any semver range (#1405)

authored by philippeserhal.com and committed by

GitHub 456daee2 c6952cec

+523 -22
+6 -2
app/components/AppFooter.vue
··· 1 1 <script setup lang="ts"> 2 + import { NPMX_DOCS_SITE } from '#shared/utils/constants' 3 + 2 4 const route = useRoute() 3 5 const isHome = computed(() => route.name === 'index') 4 6 ··· 13 15 class="flex flex-col sm:flex-row sm:flex-wrap items-center sm:items-baseline justify-between gap-2 sm:gap-4" 14 16 > 15 17 <div> 16 - <p class="font-mono text-balance m-0 hidden sm:block">{{ $t('tagline') }}</p> 18 + <p class="font-mono text-balance m-0 hidden sm:block"> 19 + {{ $t('tagline') }} 20 + </p> 17 21 </div> 18 22 <!-- Desktop: Show all links. Mobile: Links are in MobileMenu --> 19 23 <div class="hidden sm:flex items-center gap-6 min-h-11 text-xs"> ··· 92 96 </li> 93 97 </ul> 94 98 </Modal> 95 - <LinkBase to="https://docs.npmx.dev"> 99 + <LinkBase :to="NPMX_DOCS_SITE"> 96 100 {{ $t('footer.docs') }} 97 101 </LinkBase> 98 102 <LinkBase to="https://repo.npmx.dev">
+2 -1
app/components/AppHeader.vue
··· 2 2 import { LinkBase } from '#components' 3 3 import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' 4 4 import { isEditableElement } from '~/utils/input' 5 + import { NPMX_DOCS_SITE } from '#shared/utils/constants' 5 6 6 7 withDefaults( 7 8 defineProps<{ ··· 85 86 { 86 87 name: 'Docs', 87 88 label: $t('footer.docs'), 88 - href: 'https://docs.npmx.dev', 89 + href: NPMX_DOCS_SITE, 89 90 target: '_blank', 90 91 type: 'link', 91 92 external: true,
+142 -13
app/components/Package/Versions.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { PackageVersionInfo, SlimVersion } from '#shared/types' 3 - import { compare } from 'semver' 3 + import { compare, validRange } from 'semver' 4 4 import type { RouteLocationRaw } from 'vue-router' 5 5 import { fetchAllPackageVersions } from '~/utils/npm/api' 6 + import { NPMX_DOCS_SITE } from '#shared/utils/constants' 6 7 import { 7 8 buildVersionToTagsMap, 8 9 filterExcludedTags, 10 + filterVersions, 9 11 getPrereleaseChannel, 10 12 getVersionGroupKey, 11 13 getVersionGroupLabel, ··· 83 85 () => props.selectedVersion ?? props.distTags.latest ?? undefined, 84 86 ) 85 87 88 + // Semver range filter 89 + const semverFilter = ref('') 90 + // Collect all known versions: initial props + dynamically loaded ones 91 + const allKnownVersions = computed(() => { 92 + const versions = new Set(Object.keys(props.versions)) 93 + for (const versionList of tagVersions.value.values()) { 94 + for (const v of versionList) { 95 + versions.add(v.version) 96 + } 97 + } 98 + for (const group of otherMajorGroups.value) { 99 + for (const v of group.versions) { 100 + versions.add(v.version) 101 + } 102 + } 103 + return [...versions] 104 + }) 105 + const filteredVersionSet = computed(() => 106 + filterVersions(allKnownVersions.value, semverFilter.value), 107 + ) 108 + const isFilterActive = computed(() => semverFilter.value.trim() !== '') 109 + const isInvalidRange = computed( 110 + () => isFilterActive.value && validRange(semverFilter.value.trim()) === null, 111 + ) 112 + 86 113 // All tag rows derived from props (SSR-safe) 87 114 // Deduplicates so each version appears only once, with all its tags 88 115 const allTagRows = computed(() => { ··· 135 162 136 163 // Visible tag rows: limited to MAX_VISIBLE_TAGS 137 164 // If package is NOT deprecated, filter out deprecated tags from visible list 165 + // When semver filter is active, also filter by matching version 138 166 const visibleTagRows = computed(() => { 139 - const rows = isPackageDeprecated.value 167 + const rowsMaybeFilteredForDeprecation = isPackageDeprecated.value 140 168 ? allTagRows.value 141 169 : allTagRows.value.filter(row => !row.primaryVersion.deprecated) 170 + const rows = isFilterActive.value 171 + ? rowsMaybeFilteredForDeprecation.filter(row => 172 + filteredVersionSet.value.has(row.primaryVersion.version), 173 + ) 174 + : rowsMaybeFilteredForDeprecation 142 175 const first = rows.slice(0, MAX_VISIBLE_TAGS) 143 176 const latestTagRow = rows.find(row => row.tag === 'latest') 144 177 // Ensure 'latest' tag is always included (at the end) if not already present ··· 150 183 }) 151 184 152 185 // Hidden tag rows (all other tags) - shown in "Other versions" 153 - const hiddenTagRows = computed(() => 154 - allTagRows.value.filter(row => !visibleTagRows.value.includes(row)), 155 - ) 186 + // When semver filter is active, also filter by matching version 187 + const hiddenTagRows = computed(() => { 188 + const hiddenRows = allTagRows.value.filter(row => !visibleTagRows.value.includes(row)) 189 + const rows = isFilterActive.value 190 + ? hiddenRows.filter(row => filteredVersionSet.value.has(row.primaryVersion.version)) 191 + : hiddenRows 192 + return rows 193 + }) 156 194 157 195 // Client-side state for expansion and loaded versions 158 196 const expandedTags = ref<Set<string>>(new Set()) ··· 165 203 Array<{ groupKey: string; label: string; versions: VersionDisplay[] }> 166 204 >([]) 167 205 const otherVersionsLoading = shallowRef(false) 206 + 207 + // Filtered major groups (applies semver filter when active) 208 + const filteredOtherMajorGroups = computed(() => { 209 + if (!isFilterActive.value) return otherMajorGroups.value 210 + return otherMajorGroups.value 211 + .map(group => ({ 212 + ...group, 213 + versions: group.versions.filter(v => filteredVersionSet.value.has(v.version)), 214 + })) 215 + .filter(group => group.versions.length > 0) 216 + }) 217 + 218 + // Whether the filter is active but nothing matches anywhere 219 + const hasNoFilterMatches = computed(() => { 220 + if (!isFilterActive.value) return false 221 + return ( 222 + visibleTagRows.value.length === 0 && 223 + hiddenTagRows.value.length === 0 && 224 + filteredOtherMajorGroups.value.length === 0 225 + ) 226 + }) 168 227 169 228 // Cached full version list (local to component instance) 170 229 const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null) ··· 340 399 return tagVersions.value.get(tag) ?? [] 341 400 } 342 401 402 + // Get filtered versions for a tag (applies semver filter when active) 403 + function getFilteredTagVersions(tag: string): VersionDisplay[] { 404 + const versions = getTagVersions(tag) 405 + if (!isFilterActive.value) return versions 406 + return versions.filter(v => filteredVersionSet.value.has(v.version)) 407 + } 408 + 343 409 function findClaimingTag(version: string): string | null { 344 410 const versionChannel = getPrereleaseChannel(version) 345 411 ··· 418 484 </ButtonBase> 419 485 </template> 420 486 <div class="space-y-0.5 min-w-0"> 487 + <!-- Semver range filter --> 488 + <div class="px-1 pb-1"> 489 + <div class="flex items-center gap-1.5"> 490 + <InputBase 491 + v-model="semverFilter" 492 + type="text" 493 + :placeholder="$t('package.versions.filter_placeholder')" 494 + :aria-label="$t('package.versions.filter_placeholder')" 495 + :aria-invalid="isInvalidRange ? 'true' : undefined" 496 + :aria-describedby="isInvalidRange ? 'semver-filter-error' : undefined" 497 + autocomplete="off" 498 + class="flex-1 min-w-0" 499 + :class="isInvalidRange ? '!border-red-500' : ''" 500 + size="small" 501 + /> 502 + <TooltipApp interactive position="top"> 503 + <span 504 + tabindex="0" 505 + class="i-carbon:information w-3.5 h-3.5 text-fg-subtle cursor-help shrink-0 rounded-sm" 506 + role="img" 507 + :aria-label="$t('package.versions.filter_help')" 508 + /> 509 + <template #content> 510 + <p class="text-xs text-fg-muted"> 511 + <i18n-t keypath="package.versions.filter_tooltip" tag="span"> 512 + <template #link> 513 + <LinkBase :to="`${NPMX_DOCS_SITE}/guide/semver-ranges`">{{ 514 + $t('package.versions.filter_tooltip_link') 515 + }}</LinkBase> 516 + </template> 517 + </i18n-t> 518 + </p> 519 + </template> 520 + </TooltipApp> 521 + </div> 522 + <p 523 + v-if="isInvalidRange" 524 + id="semver-filter-error" 525 + class="text-red-500 text-3xs mt-1" 526 + role="alert" 527 + > 528 + {{ $t('package.versions.filter_invalid') }} 529 + </p> 530 + </div> 531 + 532 + <!-- No matches message --> 533 + <div 534 + v-if="hasNoFilterMatches" 535 + class="px-1 py-2 text-xs text-fg-subtle" 536 + role="status" 537 + aria-live="polite" 538 + > 539 + {{ $t('package.versions.no_matches') }} 540 + </div> 541 + 421 542 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) --> 422 543 <div v-for="row in visibleTagRows" :key="row.id"> 423 544 <div ··· 512 633 513 634 <!-- Expanded versions --> 514 635 <div 515 - v-if="expandedTags.has(row.tag) && getTagVersions(row.tag).length > 1" 636 + v-if="expandedTags.has(row.tag) && getFilteredTagVersions(row.tag).length > 1" 516 637 class="ms-4 ps-2 border-is border-border space-y-0.5 pe-2" 517 638 > 518 639 <div 519 - v-for="v in getTagVersions(row.tag).slice(1)" 640 + v-for="v in getFilteredTagVersions(row.tag).slice(1)" 520 641 :key="v.version" 521 642 class="py-1" 522 643 :class="v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : ''" ··· 533 654 " 534 655 :title=" 535 656 v.deprecated 536 - ? $t('package.versions.deprecated_title', { version: v.version }) 657 + ? $t('package.versions.deprecated_title', { 658 + version: v.version, 659 + }) 537 660 : v.version 538 661 " 539 662 :classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined" ··· 676 799 </div> 677 800 678 801 <!-- Version groups (untagged versions) --> 679 - <template v-if="otherMajorGroups.length > 0"> 680 - <div v-for="group in otherMajorGroups" :key="group.groupKey"> 802 + <template v-if="filteredOtherMajorGroups.length > 0"> 803 + <div v-for="group in filteredOtherMajorGroups" :key="group.groupKey"> 681 804 <!-- Version group header --> 682 805 <div 683 806 v-if="group.versions.length > 1" ··· 692 815 :aria-expanded="expandedMajorGroups.has(group.groupKey)" 693 816 :aria-label=" 694 817 expandedMajorGroups.has(group.groupKey) 695 - ? $t('package.versions.collapse_major', { major: group.label }) 696 - : $t('package.versions.expand_major', { major: group.label }) 818 + ? $t('package.versions.collapse_major', { 819 + major: group.label, 820 + }) 821 + : $t('package.versions.expand_major', { 822 + major: group.label, 823 + }) 697 824 " 698 825 data-testid="major-group-expand-button" 699 826 @click="toggleMajorGroup(group.groupKey)" ··· 852 979 " 853 980 :title=" 854 981 v.deprecated 855 - ? $t('package.versions.deprecated_title', { version: v.version }) 982 + ? $t('package.versions.deprecated_title', { 983 + version: v.version, 984 + }) 856 985 : v.version 857 986 " 858 987 :classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined"
+29 -1
app/utils/versions.ts
··· 1 - import { compare, valid } from 'semver' 1 + import { compare, satisfies, validRange, valid } from 'semver' 2 2 3 3 /** 4 4 * Utilities for handling npm package versions and dist-tags ··· 179 179 export function isSameVersionGroup(versionA: string, versionB: string): boolean { 180 180 return getVersionGroupKey(versionA) === getVersionGroupKey(versionB) 181 181 } 182 + 183 + /** 184 + * Filter versions by a semver range string. 185 + * 186 + * @param versions - Array of version strings to filter 187 + * @param range - A semver range string (e.g., "^3.0.0", ">=2.0.0 <3.0.0") 188 + * @returns Set of version strings that satisfy the range. 189 + * Returns all versions if range is empty/whitespace. 190 + * Returns empty set if range is invalid. 191 + */ 192 + export function filterVersions(versions: string[], range: string): Set<string> { 193 + const trimmed = range.trim() 194 + if (trimmed === '') { 195 + return new Set(versions) 196 + } 197 + 198 + if (!validRange(trimmed)) { 199 + return new Set() 200 + } 201 + 202 + const matched = new Set<string>() 203 + for (const v of versions) { 204 + if (satisfies(v, trimmed, { includePrerelease: true })) { 205 + matched.add(v) 206 + } 207 + } 208 + return matched 209 + }
+58
docs/content/2.guide/5.semver-ranges.md
··· 1 + --- 2 + title: Semver Ranges 3 + description: Learn how to use semver ranges to filter package versions on npmx.dev 4 + navigation: 5 + icon: i-lucide-filter 6 + --- 7 + 8 + npm uses [semantic versioning](https://semver.org/) (semver) to manage package versions. A **semver range** is a string that describes a set of version numbers. On npmx, you can type a semver range into the version filter input on any package page to quickly find matching versions. 9 + 10 + ## Version format 11 + 12 + Every npm version follows the format **MAJOR.MINOR.PATCH**, for example `3.2.1`: 13 + 14 + - **MAJOR** - incremented for breaking changes 15 + - **MINOR** - incremented for new features (backwards-compatible) 16 + - **PATCH** - incremented for bug fixes (backwards-compatible) 17 + 18 + Some versions also include a **prerelease** tag, such as `4.0.0-beta.1`. 19 + 20 + ## Common range syntax 21 + 22 + | Range | Meaning | Example matches | 23 + | ---------------- | ------------------------------------------------- | -------------------- | 24 + | `*` | Any version | 0.0.2, 3.1.0, 3.2.6 | 25 + | `^3.0.0` | Compatible with 3.x (same major) | 3.0.0, 3.1.0, 3.9.5 | 26 + | `~3.2.0` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 | 27 + | `3.2.x` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 | 28 + | `>=2.0.0 <3.0.0` | At least 2.0.0 but below 3.0.0 | 2.0.0, 2.5.3, 2.99.0 | 29 + | `1.2.3` | Exactly this version | 1.2.3 | 30 + | `=1.2.3` | Exactly this version | 1.2.3 | 31 + | `^0.3.1` | At least 0.3.1, same major.minor (0.x is special) | 0.3.1, 0.3.2 | 32 + | `^0.0.4` | Exactly 0.0.4 (0.0.x is special) | 0.0.4 (only) | 33 + 34 + ## Examples 35 + 36 + ### Find all 3.x versions 37 + 38 + Type `^3.0.0` to see every version compatible with major version 3. 39 + 40 + ### Find patch releases for a specific minor 41 + 42 + Type `~2.4.0` to see only 2.4.x patch releases (2.4.0, 2.4.1, 2.4.2, etc.). 43 + 44 + ### Find versions in a specific range 45 + 46 + Type `>=1.0.0 <2.0.0` to see all 1.x stable releases. 47 + 48 + ### Find a specific version 49 + 50 + Type the exact version number, like `5.3.1`, to check if it exists. 51 + 52 + ### Find prerelease versions 53 + 54 + Type `>=3.0.0-alpha.0` to find alpha, beta, and release candidate versions for a major release. 55 + 56 + ## Learn more 57 + 58 + The full semver range specification is documented at [node-semver](https://github.com/npm/node-semver#ranges).
+7 -1
i18n/locales/en.json
··· 300 300 "show_low_usage": "Show low usage versions", 301 301 "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads.", 302 302 "date_range_tooltip": "Last week of version distributions only", 303 - "y_axis_label": "Downloads" 303 + "y_axis_label": "Downloads", 304 + "filter_placeholder": "Filter by semver (e.g. ^3.0.0)", 305 + "filter_invalid": "Invalid semver range", 306 + "filter_help": "Semver range filter help", 307 + "filter_tooltip": "Filter versions using a {link}. For example, ^3.0.0 shows all 3.x versions.", 308 + "filter_tooltip_link": "semver range", 309 + "no_matches": "No versions match this range" 304 310 }, 305 311 "dependencies": { 306 312 "title": "Dependency ({count}) | Dependencies ({count})",
+7 -1
i18n/locales/fr-FR.json
··· 278 278 "more_tagged": "{count} de plus avec tag", 279 279 "all_covered": "Toutes les versions sont couvertes par les tags ci-dessus", 280 280 "deprecated_title": "{version} (dépréciée)", 281 - "view_all": "Voir la version | Voir les {count} versions" 281 + "view_all": "Voir la version | Voir les {count} versions", 282 + "filter_placeholder": "Filtrer par plage semver (ex. ^3.0.0)", 283 + "filter_invalid": "Plage semver invalide", 284 + "filter_help": "Infos sur le filtre de plage semver", 285 + "filter_tooltip": "Filtrer les versions avec une {link}. Par exemple, ^3.0.0 affiche toutes les versions 3.x.", 286 + "filter_tooltip_link": "plage semver", 287 + "no_matches": "Aucune version ne correspond à cette plage" 282 288 }, 283 289 "dependencies": { 284 290 "title": "Dépendances ({count})",
+18
i18n/schema.json
··· 906 906 }, 907 907 "y_axis_label": { 908 908 "type": "string" 909 + }, 910 + "filter_placeholder": { 911 + "type": "string" 912 + }, 913 + "filter_invalid": { 914 + "type": "string" 915 + }, 916 + "filter_help": { 917 + "type": "string" 918 + }, 919 + "filter_tooltip": { 920 + "type": "string" 921 + }, 922 + "filter_tooltip_link": { 923 + "type": "string" 924 + }, 925 + "no_matches": { 926 + "type": "string" 909 927 } 910 928 }, 911 929 "additionalProperties": false
+7 -1
lunaria/files/en-GB.json
··· 299 299 "show_low_usage": "Show low usage versions", 300 300 "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads.", 301 301 "date_range_tooltip": "Last week of version distributions only", 302 - "y_axis_label": "Downloads" 302 + "y_axis_label": "Downloads", 303 + "filter_placeholder": "Filter by semver (e.g. ^3.0.0)", 304 + "filter_invalid": "Invalid semver range", 305 + "filter_help": "Semver range filter help", 306 + "filter_tooltip": "Filter versions using a {link}. For example, ^3.0.0 shows all 3.x versions.", 307 + "filter_tooltip_link": "semver range", 308 + "no_matches": "No versions match this range" 303 309 }, 304 310 "dependencies": { 305 311 "title": "Dependency ({count}) | Dependencies ({count})",
+7 -1
lunaria/files/en-US.json
··· 299 299 "show_low_usage": "Show low usage versions", 300 300 "show_low_usage_tooltip": "Include version groups with less than 1% of total downloads.", 301 301 "date_range_tooltip": "Last week of version distributions only", 302 - "y_axis_label": "Downloads" 302 + "y_axis_label": "Downloads", 303 + "filter_placeholder": "Filter by semver (e.g. ^3.0.0)", 304 + "filter_invalid": "Invalid semver range", 305 + "filter_help": "Semver range filter help", 306 + "filter_tooltip": "Filter versions using a {link}. For example, ^3.0.0 shows all 3.x versions.", 307 + "filter_tooltip_link": "semver range", 308 + "no_matches": "No versions match this range" 303 309 }, 304 310 "dependencies": { 305 311 "title": "Dependency ({count}) | Dependencies ({count})",
+7 -1
lunaria/files/fr-FR.json
··· 277 277 "more_tagged": "{count} de plus avec tag", 278 278 "all_covered": "Toutes les versions sont couvertes par les tags ci-dessus", 279 279 "deprecated_title": "{version} (dépréciée)", 280 - "view_all": "Voir la version | Voir les {count} versions" 280 + "view_all": "Voir la version | Voir les {count} versions", 281 + "filter_placeholder": "Filtrer par plage semver (ex. ^3.0.0)", 282 + "filter_invalid": "Plage semver invalide", 283 + "filter_help": "Infos sur le filtre de plage semver", 284 + "filter_tooltip": "Filtrer les versions avec une {link}. Par exemple, ^3.0.0 affiche toutes les versions 3.x.", 285 + "filter_tooltip_link": "plage semver", 286 + "no_matches": "Aucune version ne correspond à cette plage" 281 287 }, 282 288 "dependencies": { 283 289 "title": "Dépendances ({count})",
+1
shared/utils/constants.ts
··· 9 9 10 10 // API Strings 11 11 export const NPMX_SITE = 'https://npmx.dev' 12 + export const NPMX_DOCS_SITE = 'https://docs.npmx.dev' 12 13 export const BLUESKY_API = 'https://public.api.bsky.app' 13 14 export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' 14 15 export const NPM_REGISTRY = 'https://registry.npmjs.org'
+187
test/nuxt/components/PackageVersions.spec.ts
··· 928 928 }) 929 929 }) 930 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 + 931 1118 describe('error handling', () => { 932 1119 it('handles fetch errors gracefully', async () => { 933 1120 mockFetchAllPackageVersions.mockRejectedValue(new Error('Network error'))
+45
test/unit/app/utils/versions.spec.ts
··· 3 3 buildTaggedVersionRows, 4 4 buildVersionToTagsMap, 5 5 filterExcludedTags, 6 + filterVersions, 6 7 getPrereleaseChannel, 7 8 getVersionGroupKey, 8 9 getVersionGroupLabel, ··· 421 422 expect(isSameVersionGroup('0.5.0-beta.1', '0.6.0')).toBe(false) 422 423 }) 423 424 }) 425 + 426 + describe('filterVersions', () => { 427 + const versions = ['1.0.0', '1.1.0', '1.5.3', '2.0.0', '2.1.0', '3.0.0-beta.1'] 428 + 429 + it('returns all versions for empty range', () => { 430 + expect(filterVersions(versions, '')).toEqual(new Set(versions)) 431 + }) 432 + 433 + it('returns all versions for whitespace-only range', () => { 434 + expect(filterVersions(versions, ' ')).toEqual(new Set(versions)) 435 + }) 436 + 437 + it('matches exact version', () => { 438 + expect(filterVersions(versions, '1.0.0')).toEqual(new Set(['1.0.0'])) 439 + }) 440 + 441 + it('matches caret range', () => { 442 + expect(filterVersions(versions, '^1.0.0')).toEqual(new Set(['1.0.0', '1.1.0', '1.5.3'])) 443 + }) 444 + 445 + it('matches tilde range', () => { 446 + expect(filterVersions(versions, '~1.0.0')).toEqual(new Set(['1.0.0'])) 447 + expect(filterVersions(versions, '~1.1.0')).toEqual(new Set(['1.1.0'])) 448 + }) 449 + 450 + it('matches complex range', () => { 451 + // 3.0.0-beta.1 is included because with includePrerelease it is < 3.0.0 452 + expect(filterVersions(versions, '>=2.0.0 <3.0.0')).toEqual( 453 + new Set(['2.0.0', '2.1.0', '3.0.0-beta.1']), 454 + ) 455 + }) 456 + 457 + it('matches prerelease versions with includePrerelease', () => { 458 + expect(filterVersions(versions, '>=3.0.0-beta.0')).toEqual(new Set(['3.0.0-beta.1'])) 459 + }) 460 + 461 + it('returns empty set for invalid range', () => { 462 + expect(filterVersions(versions, 'not-a-range!!!')).toEqual(new Set()) 463 + }) 464 + 465 + it('returns empty set for empty versions array', () => { 466 + expect(filterVersions([], '^1.0.0')).toEqual(new Set()) 467 + }) 468 + })