diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue index ae779a39..05def32e 100644 --- a/app/components/AppHeader.vue +++ b/app/components/AppHeader.vue @@ -77,6 +77,14 @@ onKeyStroke(',', e => { :class="{ 'hidden sm:flex': showFullSearch }" class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0" > + + +defineProps<{ + /** Number of columns (2-4) */ + columns: number + /** Column headers (package names or version numbers) */ + headers: string[] +}>() + + + + + diff --git a/app/components/compare/FacetRow.vue b/app/components/compare/FacetRow.vue new file mode 100644 index 00000000..55851510 --- /dev/null +++ b/app/components/compare/FacetRow.vue @@ -0,0 +1,114 @@ + + + diff --git a/app/components/compare/FacetSelector.vue b/app/components/compare/FacetSelector.vue new file mode 100644 index 00000000..a475fcc2 --- /dev/null +++ b/app/components/compare/FacetSelector.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/components/compare/PackageSelector.vue b/app/components/compare/PackageSelector.vue new file mode 100644 index 00000000..dd921626 --- /dev/null +++ b/app/components/compare/PackageSelector.vue @@ -0,0 +1,149 @@ + + + diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts new file mode 100644 index 00000000..f08695cf --- /dev/null +++ b/app/composables/useFacetSelection.ts @@ -0,0 +1,124 @@ +import type { ComparisonFacet } from '#shared/types' +import { ALL_FACETS, DEFAULT_FACETS, FACET_INFO } from '#shared/types/comparison' +import { useRouteQuery } from '@vueuse/router' + +/** + * Composable for managing comparison facet selection with URL sync. + * + * @public + * @param queryParam - The URL query parameter name to use (default: 'facets') + */ +export function useFacetSelection(queryParam = 'facets') { + // Sync with URL query param (stable ref - doesn't change on other query changes) + const facetsParam = useRouteQuery(queryParam, '', { mode: 'replace' }) + + // Parse facets from URL or use defaults + const selectedFacets = computed({ + get() { + if (!facetsParam.value) { + return DEFAULT_FACETS + } + + // Parse comma-separated facets and filter valid, non-comingSoon ones + const parsed = facetsParam.value + .split(',') + .map(f => f.trim()) + .filter( + (f): f is ComparisonFacet => + ALL_FACETS.includes(f as ComparisonFacet) && + !FACET_INFO[f as ComparisonFacet].comingSoon, + ) + + return parsed.length > 0 ? parsed : DEFAULT_FACETS + }, + set(facets) { + if (facets.length === 0 || arraysEqual(facets, DEFAULT_FACETS)) { + // Remove param if using defaults + facetsParam.value = '' + } else { + facetsParam.value = facets.join(',') + } + }, + }) + + // Check if a facet is selected + function isFacetSelected(facet: ComparisonFacet): boolean { + return selectedFacets.value.includes(facet) + } + + // Toggle a single facet + function toggleFacet(facet: ComparisonFacet): void { + const current = selectedFacets.value + if (current.includes(facet)) { + // Don't allow deselecting all facets + if (current.length > 1) { + selectedFacets.value = current.filter(f => f !== facet) + } + } else { + selectedFacets.value = [...current, facet] + } + } + + // Get facets in a category (excluding coming soon) + function getFacetsInCategory(category: string): ComparisonFacet[] { + return ALL_FACETS.filter(f => { + const info = FACET_INFO[f] + return info.category === category && !info.comingSoon + }) + } + + // Select all facets in a category + function selectCategory(category: string): void { + const categoryFacets = getFacetsInCategory(category) + const current = selectedFacets.value + const newFacets = [...new Set([...current, ...categoryFacets])] + selectedFacets.value = newFacets + } + + // Deselect all facets in a category + function deselectCategory(category: string): void { + const categoryFacets = getFacetsInCategory(category) + const remaining = selectedFacets.value.filter(f => !categoryFacets.includes(f)) + // Don't allow deselecting all facets + if (remaining.length > 0) { + selectedFacets.value = remaining + } + } + + // Select all facets globally + function selectAll(): void { + selectedFacets.value = DEFAULT_FACETS + } + + // Deselect all facets globally (keeps first facet to ensure at least one) + function deselectAll(): void { + selectedFacets.value = [DEFAULT_FACETS[0] as ComparisonFacet] + } + + // Check if all facets are selected + const isAllSelected = computed(() => selectedFacets.value.length === DEFAULT_FACETS.length) + + // Check if only one facet is selected (minimum) + const isNoneSelected = computed(() => selectedFacets.value.length === 1) + + return { + selectedFacets, + isFacetSelected, + toggleFacet, + selectCategory, + deselectCategory, + selectAll, + deselectAll, + isAllSelected, + isNoneSelected, + allFacets: ALL_FACETS, + } +} + +// Helper to compare arrays +function arraysEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort() + const sortedB = [...b].sort() + return sortedA.every((val, i) => val === sortedB[i]) +} diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts new file mode 100644 index 00000000..df77dac3 --- /dev/null +++ b/app/composables/usePackageComparison.ts @@ -0,0 +1,353 @@ +import type { FacetValue, ComparisonFacet, ComparisonPackage } from '#shared/types' +import type { PackageAnalysisResponse } from './usePackageAnalysis' + +export interface PackageComparisonData { + package: ComparisonPackage + downloads?: number + /** Package's own unpacked size (from dist.unpackedSize) */ + packageSize?: number + /** Install size data (fetched lazily) */ + installSize?: { + selfSize: number + totalSize: number + dependencyCount: number + } + analysis?: PackageAnalysisResponse + vulnerabilities?: { + count: number + severity: { critical: number; high: number; medium: number; low: number } + } + metadata?: { + license?: string + lastUpdated?: string + engines?: { node?: string; npm?: string } + deprecated?: string + } +} + +/** + * Composable for fetching and comparing multiple packages. + * + * @public + */ +export function usePackageComparison(packageNames: MaybeRefOrGetter) { + const packages = computed(() => toValue(packageNames)) + + // Cache of fetched data by package name (source of truth) + const cache = shallowRef(new Map()) + + // Derived array in current package order + const packagesData = computed(() => packages.value.map(name => cache.value.get(name) ?? null)) + + const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle') + const error = ref(null) + + // Track which packages are currently being fetched + const loadingPackages = shallowRef(new Set()) + + // Track install size loading separately (it's slower) + const installSizeLoading = ref(false) + + // Fetch function - only fetches packages not already in cache + async function fetchPackages(names: string[]) { + if (names.length === 0) { + status.value = 'idle' + return + } + + // Only fetch packages not already cached + const namesToFetch = names.filter(name => !cache.value.has(name)) + + if (namesToFetch.length === 0) { + status.value = 'success' + return + } + + status.value = 'pending' + error.value = null + + // Mark packages as loading + loadingPackages.value = new Set(namesToFetch) + + try { + // First pass: fetch fast data (package info, downloads, analysis, vulns) + const results = await Promise.all( + namesToFetch.map(async (name): Promise => { + try { + // Fetch basic package info first (required) + const pkgData = await $fetch<{ + 'name': string + 'dist-tags': Record + 'time': Record + 'license'?: string + 'versions': Record + }>(`https://registry.npmjs.org/${encodePackageName(name)}`) + + const latestVersion = pkgData['dist-tags']?.latest + if (!latestVersion) return null + + // Fetch fast additional data in parallel (optional - failures are ok) + const [downloads, analysis, vulns] = await Promise.all([ + $fetch<{ downloads: number }>( + `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, + ).catch(() => null), + $fetch(`/api/registry/analysis/${name}`).catch(() => null), + $fetch<{ + vulnerabilities: Array<{ severity: string }> + }>(`/api/registry/vulnerabilities/${name}`).catch(() => null), + ]) + + const versionData = pkgData.versions[latestVersion] + const packageSize = versionData?.dist?.unpackedSize + + // Count vulnerabilities by severity + const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 } + const vulnList = vulns?.vulnerabilities ?? [] + for (const v of vulnList) { + const sev = v.severity.toLowerCase() as keyof typeof vulnCounts + if (sev in vulnCounts) vulnCounts[sev]++ + } + + return { + package: { + name: pkgData.name, + version: latestVersion, + description: undefined, + }, + downloads: downloads?.downloads, + packageSize, + installSize: undefined, // Will be filled in second pass + analysis: analysis ?? undefined, + vulnerabilities: { + count: vulnList.length, + severity: vulnCounts, + }, + metadata: { + license: pkgData.license, + lastUpdated: pkgData.time?.modified, + engines: analysis?.engines, + deprecated: versionData?.deprecated, + }, + } + } catch { + return null + } + }), + ) + + // Add results to cache + const newCache = new Map(cache.value) + for (const [i, name] of namesToFetch.entries()) { + const data = results[i] + if (data) { + newCache.set(name, data) + } + } + cache.value = newCache + loadingPackages.value = new Set() + status.value = 'success' + + // Second pass: fetch slow install size data in background for new packages + installSizeLoading.value = true + Promise.all( + namesToFetch.map(async name => { + try { + const installSize = await $fetch<{ + selfSize: number + totalSize: number + dependencyCount: number + }>(`/api/registry/install-size/${name}`) + + // Update cache with install size + const existing = cache.value.get(name) + if (existing) { + const updated = new Map(cache.value) + updated.set(name, { ...existing, installSize }) + cache.value = updated + } + } catch { + // Install size fetch failed, leave as undefined + } + }), + ).finally(() => { + installSizeLoading.value = false + }) + } catch (e) { + loadingPackages.value = new Set() + error.value = e as Error + status.value = 'error' + } + } + + // Watch for package changes and refetch (client-side only) + if (import.meta.client) { + watch( + packages, + newPackages => { + fetchPackages(newPackages) + }, + { immediate: true }, + ) + } + + // Compute values for each facet + function getFacetValues(facet: ComparisonFacet): (FacetValue | null)[] { + if (!packagesData.value || packagesData.value.length === 0) return [] + + return packagesData.value.map(pkg => { + if (!pkg) return null + return computeFacetValue(facet, pkg) + }) + } + + // Check if a facet depends on slow-loading data + function isFacetLoading(facet: ComparisonFacet): boolean { + if (!installSizeLoading.value) return false + // These facets depend on install-size API + return facet === 'installSize' || facet === 'dependencies' + } + + // Check if a specific column (package) is loading + function isColumnLoading(index: number): boolean { + const name = packages.value[index] + return name ? loadingPackages.value.has(name) : false + } + + return { + packagesData: readonly(packagesData), + status: readonly(status), + error: readonly(error), + getFacetValues, + isFacetLoading, + isColumnLoading, + } +} + +function encodePackageName(name: string): string { + if (name.startsWith('@')) { + return `@${encodeURIComponent(name.slice(1))}` + } + return encodeURIComponent(name) +} + +function computeFacetValue(facet: ComparisonFacet, data: PackageComparisonData): FacetValue | null { + switch (facet) { + case 'downloads': + if (data.downloads === undefined) return null + return { + raw: data.downloads, + display: formatCompactNumber(data.downloads), + status: 'neutral', + } + + case 'packageSize': + if (!data.packageSize) return null + return { + raw: data.packageSize, + display: formatBytes(data.packageSize), + status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral', + } + + case 'installSize': + if (!data.installSize) return null + return { + raw: data.installSize.totalSize, + display: formatBytes(data.installSize.totalSize), + status: data.installSize.totalSize > 50 * 1024 * 1024 ? 'warning' : 'neutral', + } + + case 'moduleFormat': + if (!data.analysis) return null + const format = data.analysis.moduleFormat + return { + raw: format, + display: format === 'dual' ? 'ESM + CJS' : format.toUpperCase(), + status: format === 'esm' || format === 'dual' ? 'good' : 'neutral', + } + + case 'types': + if (!data.analysis) return null + const types = data.analysis.types + return { + raw: types.kind, + display: + types.kind === 'included' ? 'Included' : types.kind === '@types' ? '@types' : 'None', + status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad', + } + + case 'engines': + const engines = data.metadata?.engines + if (!engines?.node) return { raw: null, display: 'Any', status: 'neutral' } + return { + raw: engines.node, + display: `Node ${engines.node}`, + status: 'neutral', + } + + case 'vulnerabilities': + if (!data.vulnerabilities) return null + const count = data.vulnerabilities.count + const sev = data.vulnerabilities.severity + return { + raw: count, + display: count === 0 ? 'None' : `${count} (${sev.critical}C/${sev.high}H)`, + status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning', + } + + case 'lastUpdated': + if (!data.metadata?.lastUpdated) return null + const date = new Date(data.metadata.lastUpdated) + return { + raw: date.getTime(), + display: data.metadata.lastUpdated, + status: isStale(date) ? 'warning' : 'neutral', + type: 'date', + } + + case 'license': + const license = data.metadata?.license + if (!license) return { raw: null, display: 'Unknown', status: 'warning' } + return { + raw: license, + display: license, + status: 'neutral', + } + + case 'dependencies': + if (!data.installSize) return null + const depCount = data.installSize.dependencyCount + return { + raw: depCount, + display: String(depCount), + status: depCount > 50 ? 'warning' : 'neutral', + } + + case 'deprecated': + const isDeprecated = !!data.metadata?.deprecated + return { + raw: isDeprecated, + display: isDeprecated ? 'Deprecated' : 'No', + status: isDeprecated ? 'bad' : 'good', + } + + // Coming soon facets + case 'totalDependencies': + return null + + default: + return null + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function isStale(date: Date): boolean { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365) + return diffYears > 2 // Considered stale if not updated in 2+ years +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 0be15840..0fb68b3c 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -342,6 +342,12 @@ onKeyStroke('d', () => { } }) +onKeyStroke('c', () => { + if (pkg.value) { + router.push({ path: '/compare', query: { packages: pkg.value.name } }) + } +}) + defineOgImageComponent('Package', { name: () => pkg.value?.name ?? 'Package', version: () => displayVersion.value?.version ?? '', @@ -448,11 +454,11 @@ function handleClick(event: MouseEvent) { - + @@ -594,7 +614,7 @@ function handleClick(event: MouseEvent) { {{ $t('package.links.fund') }} - +
  • +
  • + + +
  • diff --git a/app/pages/compare.vue b/app/pages/compare.vue new file mode 100644 index 00000000..2175d966 --- /dev/null +++ b/app/pages/compare.vue @@ -0,0 +1,153 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 546e4c62..0c2a1bb8 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -43,6 +43,7 @@ "popular_packages": "Popular packages", "search": "search", "settings": "settings", + "compare": "compare", "back": "back" }, "settings": { @@ -146,7 +147,8 @@ "jsr": "jsr", "code": "code", "docs": "docs", - "fund": "fund" + "fund": "fund", + "compare": "compare" }, "docs": { "not_available": "Docs not available", @@ -748,5 +750,49 @@ "empty": "No organizations found", "view_all": "View all" } + }, + "compare": { + "packages": { + "title": "Compare Packages", + "tagline": "Compare npm packages side-by-side to help you choose the right one.", + "meta_title": "Compare {packages} - npmx", + "meta_title_empty": "Compare Packages - npmx", + "meta_description": "Side-by-side comparison of {packages}", + "meta_description_empty": "Compare npm packages side-by-side", + "section_packages": "Packages", + "section_facets": "Facets", + "section_comparison": "Comparison", + "loading": "Loading package data...", + "error": "Failed to load package data. Please try again.", + "empty_title": "Select packages to compare", + "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics." + }, + "selector": { + "search_label": "Search for packages", + "search_first": "Search for a package...", + "search_add": "Add another package...", + "searching": "Searching...", + "remove_package": "Remove {package}", + "packages_selected": "{count}/{max} packages selected.", + "add_hint": "Add at least 2 packages to compare.", + "loading_versions": "Loading versions...", + "select_version": "Select version" + }, + "facets": { + "group_label": "Comparison facets", + "all": "all", + "none": "none", + "coming_soon": "Coming soon", + "select_all": "Select all facets", + "deselect_all": "Deselect all facets", + "select_category": "Select all {category} facets", + "deselect_category": "Deselect all {category} facets", + "categories": { + "performance": "Performance", + "health": "Health", + "compatibility": "Compatibility", + "security": "Security & Compliance" + } + } } } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index bdaef15d..c910e3d8 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -132,7 +132,8 @@ "jsr": "jsr", "code": "code", "docs": "docs", - "fund": "donner" + "fund": "donner", + "compare": "comparer" }, "docs": { "not_available": "Documentation non disponible", @@ -734,5 +735,49 @@ "empty": "Aucune organisation trouvée", "view_all": "Tout voir" } + }, + "compare": { + "packages": { + "title": "Comparer les paquets", + "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.", + "meta_title": "Comparer {packages} - npmx", + "meta_title_empty": "Comparer les paquets - npmx", + "meta_description": "Comparaison côte à côte de {packages}", + "meta_description_empty": "Comparez les paquets npm côte à côte", + "section_packages": "Paquets", + "section_facets": "Facettes", + "section_comparison": "Comparaison", + "loading": "Chargement des données des paquets...", + "error": "Échec du chargement des données. Veuillez réessayer.", + "empty_title": "Sélectionnez des paquets à comparer", + "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs facettes." + }, + "selector": { + "search_label": "Rechercher des paquets", + "search_first": "Rechercher un paquet...", + "search_add": "Ajouter un autre paquet...", + "searching": "Recherche...", + "remove_package": "Supprimer {package}", + "packages_selected": "{count}/{max} paquets sélectionnés.", + "add_hint": "Ajoutez au moins 2 paquets à comparer.", + "loading_versions": "Chargement des versions...", + "select_version": "Sélectionner une version" + }, + "facets": { + "group_label": "Facettes de comparaison", + "all": "tout", + "none": "aucun", + "coming_soon": "Bientôt disponible", + "select_all": "Sélectionner toutes les facettes", + "deselect_all": "Désélectionner toutes les facettes", + "select_category": "Sélectionner toutes les facettes {category}", + "deselect_category": "Désélectionner toutes les facettes {category}", + "categories": { + "performance": "Performance", + "health": "Santé", + "compatibility": "Compatibilité", + "security": "Sécurité & Conformité" + } + } } } diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 546e4c62..0c2a1bb8 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -43,6 +43,7 @@ "popular_packages": "Popular packages", "search": "search", "settings": "settings", + "compare": "compare", "back": "back" }, "settings": { @@ -146,7 +147,8 @@ "jsr": "jsr", "code": "code", "docs": "docs", - "fund": "fund" + "fund": "fund", + "compare": "compare" }, "docs": { "not_available": "Docs not available", @@ -748,5 +750,49 @@ "empty": "No organizations found", "view_all": "View all" } + }, + "compare": { + "packages": { + "title": "Compare Packages", + "tagline": "Compare npm packages side-by-side to help you choose the right one.", + "meta_title": "Compare {packages} - npmx", + "meta_title_empty": "Compare Packages - npmx", + "meta_description": "Side-by-side comparison of {packages}", + "meta_description_empty": "Compare npm packages side-by-side", + "section_packages": "Packages", + "section_facets": "Facets", + "section_comparison": "Comparison", + "loading": "Loading package data...", + "error": "Failed to load package data. Please try again.", + "empty_title": "Select packages to compare", + "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics." + }, + "selector": { + "search_label": "Search for packages", + "search_first": "Search for a package...", + "search_add": "Add another package...", + "searching": "Searching...", + "remove_package": "Remove {package}", + "packages_selected": "{count}/{max} packages selected.", + "add_hint": "Add at least 2 packages to compare.", + "loading_versions": "Loading versions...", + "select_version": "Select version" + }, + "facets": { + "group_label": "Comparison facets", + "all": "all", + "none": "none", + "coming_soon": "Coming soon", + "select_all": "Select all facets", + "deselect_all": "Deselect all facets", + "select_category": "Select all {category} facets", + "deselect_category": "Deselect all {category} facets", + "categories": { + "performance": "Performance", + "health": "Health", + "compatibility": "Compatibility", + "security": "Security & Compliance" + } + } } } diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index bdaef15d..c910e3d8 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -132,7 +132,8 @@ "jsr": "jsr", "code": "code", "docs": "docs", - "fund": "donner" + "fund": "donner", + "compare": "comparer" }, "docs": { "not_available": "Documentation non disponible", @@ -734,5 +735,49 @@ "empty": "Aucune organisation trouvée", "view_all": "Tout voir" } + }, + "compare": { + "packages": { + "title": "Comparer les paquets", + "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.", + "meta_title": "Comparer {packages} - npmx", + "meta_title_empty": "Comparer les paquets - npmx", + "meta_description": "Comparaison côte à côte de {packages}", + "meta_description_empty": "Comparez les paquets npm côte à côte", + "section_packages": "Paquets", + "section_facets": "Facettes", + "section_comparison": "Comparaison", + "loading": "Chargement des données des paquets...", + "error": "Échec du chargement des données. Veuillez réessayer.", + "empty_title": "Sélectionnez des paquets à comparer", + "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs facettes." + }, + "selector": { + "search_label": "Rechercher des paquets", + "search_first": "Rechercher un paquet...", + "search_add": "Ajouter un autre paquet...", + "searching": "Recherche...", + "remove_package": "Supprimer {package}", + "packages_selected": "{count}/{max} paquets sélectionnés.", + "add_hint": "Ajoutez au moins 2 paquets à comparer.", + "loading_versions": "Chargement des versions...", + "select_version": "Sélectionner une version" + }, + "facets": { + "group_label": "Facettes de comparaison", + "all": "tout", + "none": "aucun", + "coming_soon": "Bientôt disponible", + "select_all": "Sélectionner toutes les facettes", + "deselect_all": "Désélectionner toutes les facettes", + "select_category": "Sélectionner toutes les facettes {category}", + "deselect_category": "Désélectionner toutes les facettes {category}", + "categories": { + "performance": "Performance", + "health": "Santé", + "compatibility": "Compatibilité", + "security": "Sécurité & Conformité" + } + } } } diff --git a/package.json b/package.json index 63f16c87..26f90441 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@shikijs/themes": "3.21.0", "@vueuse/core": "14.1.0", "@vueuse/nuxt": "14.1.0", + "@vueuse/router": "^14.1.0", "module-replacements": "2.11.0", "nuxt": "4.3.0", "nuxt-og-image": "5.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2afb35..59bf943a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@vueuse/nuxt': specifier: 14.1.0 version: 14.1.0(magicast@0.5.1)(nuxt@4.3.0(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.3))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + '@vueuse/router': + specifier: ^14.1.0 + version: 14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) module-replacements: specifier: 2.11.0 version: 2.11.0 @@ -4331,6 +4334,12 @@ packages: nuxt: ^3.0.0 || ^4.0.0-0 vue: ^3.5.0 + '@vueuse/router@14.1.0': + resolution: {integrity: sha512-8h7g0PhjcMC2Vnu9zBkN1J038JFIzkS3/DP2L5ouzFEhY3YAM8zkIOZ0K+hzAWkYEFLGmWGcgBfuvCUD0U42Jw==} + peerDependencies: + vue: ^3.5.0 + vue-router: ^4.0.0 + '@vueuse/shared@10.11.1': resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} @@ -13844,6 +13853,12 @@ snapshots: transitivePeerDependencies: - magicast + '@vueuse/router@14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@vueuse/shared': 14.1.0(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.27(typescript@5.9.3)) + '@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))': dependencies: vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3)) diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts new file mode 100644 index 00000000..4ce4e5f9 --- /dev/null +++ b/shared/types/comparison.ts @@ -0,0 +1,137 @@ +/** + * Comparison feature types + */ + +/** Available comparison facets */ +export type ComparisonFacet = + | 'downloads' + | 'packageSize' + | 'installSize' + | 'moduleFormat' + | 'types' + | 'engines' + | 'vulnerabilities' + | 'lastUpdated' + | 'license' + | 'dependencies' + | 'totalDependencies' + | 'deprecated' + +/** Facet metadata for UI display */ +export interface FacetInfo { + id: ComparisonFacet + label: string + description: string + category: 'performance' | 'health' | 'compatibility' | 'security' + comingSoon?: boolean +} + +/** Category display order */ +export const CATEGORY_ORDER = ['performance', 'health', 'compatibility', 'security'] as const + +/** All available facets with their metadata (ordered by category, then display order within category) */ +export const FACET_INFO: Record> = { + // Performance + packageSize: { + label: 'Package Size', + description: 'Size of the package itself (unpacked)', + category: 'performance', + }, + installSize: { + label: 'Install Size', + description: 'Total install size including all dependencies', + category: 'performance', + }, + dependencies: { + label: '# Direct Deps', + description: 'Number of direct dependencies', + category: 'performance', + }, + totalDependencies: { + label: '# Total Deps', + description: 'Total number of dependencies including transitive', + category: 'performance', + comingSoon: true, + }, + // Health + downloads: { + label: 'Downloads/wk', + description: 'Weekly download count', + category: 'health', + }, + lastUpdated: { + label: 'Last Updated', + description: 'Most recent publish date', + category: 'health', + }, + deprecated: { + label: 'Deprecated?', + description: 'Whether the package is deprecated', + category: 'health', + }, + // Compatibility + engines: { + label: 'Engines', + description: 'Node.js version requirements', + category: 'compatibility', + }, + types: { + label: 'Types', + description: 'TypeScript type definitions', + category: 'compatibility', + }, + moduleFormat: { + label: 'Module Format', + description: 'ESM/CJS support', + category: 'compatibility', + }, + // Security + license: { + label: 'License', + description: 'Package license', + category: 'security', + }, + vulnerabilities: { + label: 'Vulnerabilities', + description: 'Known security vulnerabilities', + category: 'security', + }, +} + +/** All facets in display order */ +export const ALL_FACETS: ComparisonFacet[] = Object.keys(FACET_INFO) as ComparisonFacet[] + +/** Facets grouped by category (derived from FACET_INFO) */ +export const FACETS_BY_CATEGORY: Record = + ALL_FACETS.reduce( + (acc, facet) => { + acc[FACET_INFO[facet].category].push(facet) + return acc + }, + { performance: [], health: [], compatibility: [], security: [] } as Record< + FacetInfo['category'], + ComparisonFacet[] + >, + ) + +/** Default facets - all non-comingSoon facets */ +export const DEFAULT_FACETS: ComparisonFacet[] = ALL_FACETS.filter(f => !FACET_INFO[f].comingSoon) + +/** Facet value that can be compared */ +export interface FacetValue { + /** Raw value for comparison logic */ + raw: T + /** Formatted display string (or ISO date string if type is 'date') */ + display: string + /** Optional status indicator */ + status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral' + /** Value type for special rendering (e.g., dates use DateTime component) */ + type?: 'date' +} + +/** Package data for comparison */ +export interface ComparisonPackage { + name: string + version: string + description?: string +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 1bb0a8a1..0459b691 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -5,3 +5,4 @@ export * from './readme' export * from './docs' export * from './deno-doc' export * from './i18n-status' +export * from './comparison' diff --git a/test/nuxt/components.spec.ts b/test/nuxt/components.spec.ts index 7d47dd49..8f3ebc6d 100644 --- a/test/nuxt/components.spec.ts +++ b/test/nuxt/components.spec.ts @@ -95,6 +95,10 @@ import ViewModeToggle from '~/components/ViewModeToggle.vue' import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue' import DependencyPathPopup from '~/components/DependencyPathPopup.vue' +import CompareFacetSelector from '~/components/compare/FacetSelector.vue' +import ComparePackageSelector from '~/components/compare/PackageSelector.vue' +import CompareFacetRow from '~/components/compare/FacetRow.vue' +import CompareComparisonGrid from '~/components/compare/ComparisonGrid.vue' describe('component accessibility audits', () => { describe('DateTime', () => { @@ -1293,4 +1297,99 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) }) + + // Compare feature components + describe('CompareFacetSelector', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(CompareFacetSelector) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('ComparePackageSelector', () => { + it('should have no accessibility violations with no packages', async () => { + const component = await mountSuspended(ComparePackageSelector, { + props: { modelValue: [] }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with packages selected', async () => { + const component = await mountSuspended(ComparePackageSelector, { + props: { modelValue: ['vue', 'react'] }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations at max packages', async () => { + const component = await mountSuspended(ComparePackageSelector, { + props: { modelValue: ['vue', 'react', 'angular', 'svelte'], max: 4 }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('CompareFacetRow', () => { + it('should have no accessibility violations with basic values', async () => { + const component = await mountSuspended(CompareFacetRow, { + props: { + label: 'Downloads', + description: 'Weekly download count', + values: [ + { raw: 1000, display: '1,000' }, + { raw: 2000, display: '2,000' }, + ], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when loading', async () => { + const component = await mountSuspended(CompareFacetRow, { + props: { + label: 'Install Size', + description: 'Total install size', + values: [null, null], + loading: true, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('CompareComparisonGrid', () => { + it('should have no accessibility violations with 2 columns', async () => { + const component = await mountSuspended(CompareComparisonGrid, { + props: { + columns: 2, + headers: ['vue', 'react'], + }, + slots: { + default: '
    Grid content
    ', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with 3 columns', async () => { + const component = await mountSuspended(CompareComparisonGrid, { + props: { + columns: 3, + headers: ['vue', 'react', 'angular'], + }, + slots: { + default: '
    Grid content
    ', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) }) diff --git a/test/nuxt/components/compare/ComparisonGrid.spec.ts b/test/nuxt/components/compare/ComparisonGrid.spec.ts new file mode 100644 index 00000000..dd29e7b3 --- /dev/null +++ b/test/nuxt/components/compare/ComparisonGrid.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import ComparisonGrid from '~/components/compare/ComparisonGrid.vue' + +describe('ComparisonGrid', () => { + describe('header rendering', () => { + it('renders column headers', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 2, + headers: ['lodash@4.17.21', 'underscore@1.13.6'], + }, + }) + expect(component.text()).toContain('lodash@4.17.21') + expect(component.text()).toContain('underscore@1.13.6') + }) + + it('renders correct number of header cells', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 3, + headers: ['pkg1', 'pkg2', 'pkg3'], + }, + }) + const headerCells = component.findAll('.comparison-cell-header') + expect(headerCells.length).toBe(3) + }) + + it('truncates long header text with title attribute', async () => { + const longName = 'very-long-package-name@1.0.0-beta.1' + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 2, + headers: [longName, 'short'], + }, + }) + const spans = component.findAll('.truncate') + const longSpan = spans.find(s => s.text() === longName) + expect(longSpan?.attributes('title')).toBe(longName) + }) + }) + + describe('column layout', () => { + it('applies columns-2 class for 2 columns', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 2, + headers: ['a', 'b'], + }, + }) + expect(component.find('.columns-2').exists()).toBe(true) + }) + + it('applies columns-3 class for 3 columns', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 3, + headers: ['a', 'b', 'c'], + }, + }) + expect(component.find('.columns-3').exists()).toBe(true) + }) + + it('applies columns-4 class for 4 columns', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 4, + headers: ['a', 'b', 'c', 'd'], + }, + }) + expect(component.find('.columns-4').exists()).toBe(true) + }) + + it('sets min-width for 4 columns to 800px', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 4, + headers: ['a', 'b', 'c', 'd'], + }, + }) + expect(component.find('.min-w-\\[800px\\]').exists()).toBe(true) + }) + + it('sets min-width for 2-3 columns to 600px', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 2, + headers: ['a', 'b'], + }, + }) + expect(component.find('.min-w-\\[600px\\]').exists()).toBe(true) + }) + + it('sets --columns CSS variable', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 3, + headers: ['a', 'b', 'c'], + }, + }) + const grid = component.find('.comparison-grid') + expect(grid.attributes('style')).toContain('--columns: 3') + }) + }) + + describe('slot content', () => { + it('renders default slot content', async () => { + const component = await mountSuspended(ComparisonGrid, { + props: { + columns: 2, + headers: ['a', 'b'], + }, + slots: { + default: '
    Row content
    ', + }, + }) + expect(component.find('.test-row').exists()).toBe(true) + expect(component.text()).toContain('Row content') + }) + }) +}) diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts new file mode 100644 index 00000000..8f04ed53 --- /dev/null +++ b/test/nuxt/components/compare/FacetRow.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import FacetRow from '~/components/compare/FacetRow.vue' + +// Mock useRelativeDates for DateTime component +vi.mock('~/composables/useSettings', () => ({ + useRelativeDates: () => ref(false), + useSettings: () => ({ + settings: ref({ relativeDates: false }), + }), + useAccentColor: () => ({}), + initAccentOnPrehydrate: () => {}, +})) + +describe('FacetRow', () => { + const baseProps = { + label: 'Downloads', + values: [], + } + + describe('label rendering', () => { + it('renders the label', async () => { + const component = await mountSuspended(FacetRow, { + props: { ...baseProps, label: 'Weekly Downloads' }, + }) + expect(component.text()).toContain('Weekly Downloads') + }) + + it('renders description tooltip icon when provided', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + description: 'Number of downloads per week', + }, + }) + expect(component.find('.i-carbon\\:information').exists()).toBe(true) + }) + + it('does not render description icon when not provided', async () => { + const component = await mountSuspended(FacetRow, { + props: baseProps, + }) + expect(component.find('.i-carbon\\:information').exists()).toBe(false) + }) + }) + + describe('value rendering', () => { + it('renders null values as dash', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [null, null], + }, + }) + const cells = component.findAll('.comparison-cell') + expect(cells.length).toBe(2) + expect(component.text()).toContain('-') + }) + + it('renders facet values', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { raw: 1000, display: '1K', status: 'neutral' }, + { raw: 2000, display: '2K', status: 'neutral' }, + ], + }, + }) + expect(component.text()).toContain('1K') + expect(component.text()).toContain('2K') + }) + + it('renders loading state for facet loading', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [null, null], + facetLoading: true, + }, + }) + // All cells should show loading spinner + expect(component.findAll('.i-carbon\\:circle-dash').length).toBe(2) + }) + + it('renders loading state for specific column loading', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: 1000, display: '1K', status: 'neutral' }, null], + columnLoading: [false, true], + }, + }) + // Only second cell should show loading spinner + const spinners = component.findAll('.i-carbon\\:circle-dash') + expect(spinners.length).toBe(1) + }) + }) + + describe('status styling', () => { + it('applies good status class', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: 0, display: 'None', status: 'good' }], + }, + }) + expect(component.find('.text-emerald-400').exists()).toBe(true) + }) + + it('applies warning status class', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: 100, display: '100 MB', status: 'warning' }], + }, + }) + expect(component.find('.text-amber-400').exists()).toBe(true) + }) + + it('applies bad status class', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: 5, display: '5 critical', status: 'bad' }], + }, + }) + expect(component.find('.text-red-400').exists()).toBe(true) + }) + + it('applies info status class', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: '@types', display: '@types', status: 'info' }], + }, + }) + expect(component.find('.text-blue-400').exists()).toBe(true) + }) + }) + + describe('bar visualization', () => { + it('shows bar for numeric values when bar prop is true', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { raw: 100, display: '100', status: 'neutral' }, + { raw: 200, display: '200', status: 'neutral' }, + ], + bar: true, + }, + }) + // Bar elements have bg-fg/5 class + expect(component.findAll('.bg-fg\\/5').length).toBeGreaterThan(0) + }) + + it('hides bar when bar prop is false', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { raw: 100, display: '100', status: 'neutral' }, + { raw: 200, display: '200', status: 'neutral' }, + ], + bar: false, + }, + }) + expect(component.findAll('.bg-fg\\/5').length).toBe(0) + }) + + it('does not show bar for non-numeric values', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { raw: 'MIT', display: 'MIT', status: 'neutral' }, + { raw: 'Apache-2.0', display: 'Apache-2.0', status: 'neutral' }, + ], + }, + }) + expect(component.findAll('.bg-fg\\/5').length).toBe(0) + }) + }) + + describe('date values', () => { + it('renders DateTime component for date type values', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { + raw: Date.now(), + display: '2024-01-15T12:00:00.000Z', + status: 'neutral', + type: 'date', + }, + ], + bar: false, // Disable bar for date values + }, + }) + // DateTime component renders a time element + expect(component.find('time').exists()).toBe(true) + }) + }) + + describe('grid layout', () => { + it('uses contents display for grid integration', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [{ raw: 100, display: '100', status: 'neutral' }], + }, + }) + expect(component.find('.contents').exists()).toBe(true) + }) + + it('renders correct number of cells for values', async () => { + const component = await mountSuspended(FacetRow, { + props: { + ...baseProps, + values: [ + { raw: 1, display: '1', status: 'neutral' }, + { raw: 2, display: '2', status: 'neutral' }, + { raw: 3, display: '3', status: 'neutral' }, + ], + }, + }) + // 1 label cell + 3 value cells + const cells = component.findAll('.comparison-cell') + expect(cells.length).toBe(3) + }) + }) +}) diff --git a/test/nuxt/components/compare/FacetSelector.spec.ts b/test/nuxt/components/compare/FacetSelector.spec.ts new file mode 100644 index 00000000..71d4c2bc --- /dev/null +++ b/test/nuxt/components/compare/FacetSelector.spec.ts @@ -0,0 +1,239 @@ +import type { ComparisonFacet } from '#shared/types/comparison' +import { CATEGORY_ORDER, FACET_INFO, FACETS_BY_CATEGORY } from '#shared/types/comparison' +import FacetSelector from '~/components/compare/FacetSelector.vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { mountSuspended } from '@nuxt/test-utils/runtime' + +// Mock useFacetSelection +const mockSelectedFacets = ref(['downloads', 'types']) +const mockIsFacetSelected = vi.fn((facet: string) => mockSelectedFacets.value.includes(facet)) +const mockToggleFacet = vi.fn() +const mockSelectCategory = vi.fn() +const mockDeselectCategory = vi.fn() +const mockSelectAll = vi.fn() +const mockDeselectAll = vi.fn() +const mockIsAllSelected = ref(false) +const mockIsNoneSelected = ref(false) + +vi.mock('~/composables/useFacetSelection', () => ({ + useFacetSelection: () => ({ + selectedFacets: mockSelectedFacets, + isFacetSelected: mockIsFacetSelected, + toggleFacet: mockToggleFacet, + selectCategory: mockSelectCategory, + deselectCategory: mockDeselectCategory, + selectAll: mockSelectAll, + deselectAll: mockDeselectAll, + isAllSelected: mockIsAllSelected, + isNoneSelected: mockIsNoneSelected, + }), +})) + +// Mock useRouteQuery for composable +vi.mock('@vueuse/router', () => ({ + useRouteQuery: () => ref(''), +})) + +describe('FacetSelector', () => { + beforeEach(() => { + mockSelectedFacets.value = ['downloads', 'types'] + mockIsFacetSelected.mockImplementation((facet: string) => + mockSelectedFacets.value.includes(facet), + ) + mockToggleFacet.mockClear() + mockSelectCategory.mockClear() + mockDeselectCategory.mockClear() + mockSelectAll.mockClear() + mockDeselectAll.mockClear() + mockIsAllSelected.value = false + mockIsNoneSelected.value = false + }) + + describe('category rendering', () => { + it('renders all categories', async () => { + const component = await mountSuspended(FacetSelector) + + for (const category of CATEGORY_ORDER) { + // Categories are rendered as uppercase text + expect(component.text().toLowerCase()).toContain(category) + } + }) + + it('renders category headers with all/none buttons', async () => { + const component = await mountSuspended(FacetSelector) + + // Each category has all/none buttons + const allButtons = component.findAll('button').filter(b => b.text() === 'all') + const noneButtons = component.findAll('button').filter(b => b.text() === 'none') + + // 4 categories = 4 all buttons + 4 none buttons + expect(allButtons.length).toBe(4) + expect(noneButtons.length).toBe(4) + }) + }) + + describe('facet buttons', () => { + it('renders all facets from FACET_INFO', async () => { + const component = await mountSuspended(FacetSelector) + + for (const facet of Object.keys(FACET_INFO)) { + const facetInfo = FACET_INFO[facet as keyof typeof FACET_INFO] + expect(component.text()).toContain(facetInfo.label) + } + }) + + it('shows checkmark icon for selected facets', async () => { + mockSelectedFacets.value = ['downloads'] + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') + + const component = await mountSuspended(FacetSelector) + + expect(component.find('.i-carbon\\:checkmark').exists()).toBe(true) + }) + + it('shows add icon for unselected facets', async () => { + mockSelectedFacets.value = ['downloads'] + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') + + const component = await mountSuspended(FacetSelector) + + expect(component.find('.i-carbon\\:add').exists()).toBe(true) + }) + + it('applies aria-pressed for selected state', async () => { + mockSelectedFacets.value = ['downloads'] + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') + + const component = await mountSuspended(FacetSelector) + + const buttons = component.findAll('button[aria-pressed]') + const selectedButton = buttons.find(b => b.attributes('aria-pressed') === 'true') + expect(selectedButton).toBeDefined() + }) + + it('calls toggleFacet when facet button is clicked', async () => { + const component = await mountSuspended(FacetSelector) + + // Find a facet button (not all/none) + const facetButton = component.findAll('button').find(b => b.text().includes('Downloads')) + await facetButton?.trigger('click') + + expect(mockToggleFacet).toHaveBeenCalled() + }) + }) + + describe('comingSoon facets', () => { + it('disables comingSoon facets', async () => { + const component = await mountSuspended(FacetSelector) + + // totalDependencies is marked as comingSoon + const buttons = component.findAll('button') + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) + + expect(comingSoonButton?.attributes('disabled')).toBeDefined() + }) + + it('shows coming soon text for comingSoon facets', async () => { + const component = await mountSuspended(FacetSelector) + + expect(component.text().toLowerCase()).toContain('coming soon') + }) + + it('does not show checkmark/add icon for comingSoon facets', async () => { + const component = await mountSuspended(FacetSelector) + + // Find the comingSoon button + const buttons = component.findAll('button') + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) + + // Should not have checkmark or add icon + expect(comingSoonButton?.find('.i-carbon\\:checkmark').exists()).toBe(false) + expect(comingSoonButton?.find('.i-carbon\\:add').exists()).toBe(false) + }) + + it('does not call toggleFacet when comingSoon facet is clicked', async () => { + const component = await mountSuspended(FacetSelector) + + const buttons = component.findAll('button') + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) + await comingSoonButton?.trigger('click') + + // toggleFacet should not have been called with totalDependencies + expect(mockToggleFacet).not.toHaveBeenCalledWith('totalDependencies') + }) + }) + + describe('category all/none buttons', () => { + it('calls selectCategory when all button is clicked', async () => { + const component = await mountSuspended(FacetSelector) + + // Find the first 'all' button (for performance category) + const allButton = component.findAll('button').find(b => b.text() === 'all') + await allButton!.trigger('click') + + expect(mockSelectCategory).toHaveBeenCalledWith('performance') + }) + + it('calls deselectCategory when none button is clicked', async () => { + // Select a performance facet so 'none' button is enabled + mockSelectedFacets.value = ['packageSize'] + mockIsFacetSelected.mockImplementation((f: string) => f === 'packageSize') + + const component = await mountSuspended(FacetSelector) + + // Find the first 'none' button (for performance category) + const noneButton = component.findAll('button').find(b => b.text() === 'none') + await noneButton!.trigger('click') + + expect(mockDeselectCategory).toHaveBeenCalledWith('performance') + }) + + it('disables all button when all facets in category are selected', async () => { + // Select all performance facets + const performanceFacets = FACETS_BY_CATEGORY.performance.filter( + f => !FACET_INFO[f].comingSoon, + ) + mockSelectedFacets.value = performanceFacets + mockIsFacetSelected.mockImplementation((f: string) => + performanceFacets.includes(f as ComparisonFacet), + ) + + const component = await mountSuspended(FacetSelector) + + const allButton = component.findAll('button').find(b => b.text() === 'all') + // First all button (performance) should be disabled + expect(allButton!.attributes('disabled')).toBeDefined() + }) + + it('disables none button when no facets in category are selected', async () => { + // Deselect all performance facets + mockSelectedFacets.value = ['downloads'] // only health facet selected + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') + + const component = await mountSuspended(FacetSelector) + + const noneButton = component.findAll('button').find(b => b.text() === 'none') + // First none button (performance) should be disabled + expect(noneButton!.attributes('disabled')).toBeDefined() + }) + }) + + describe('styling', () => { + it('applies selected styling to selected facets', async () => { + mockSelectedFacets.value = ['downloads'] + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') + + const component = await mountSuspended(FacetSelector) + + // Selected facets have bg-bg-muted class + expect(component.find('.bg-bg-muted').exists()).toBe(true) + }) + + it('applies cursor-not-allowed to comingSoon facets', async () => { + const component = await mountSuspended(FacetSelector) + + expect(component.find('.cursor-not-allowed').exists()).toBe(true) + }) + }) +}) diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts new file mode 100644 index 00000000..a630c3e1 --- /dev/null +++ b/test/nuxt/components/compare/PackageSelector.spec.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import PackageSelector from '~/components/compare/PackageSelector.vue' + +// Mock $fetch for useNpmSearch +const mockFetch = vi.fn() +vi.stubGlobal('$fetch', mockFetch) + +describe('PackageSelector', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockResolvedValue({ + objects: [ + { package: { name: 'lodash', description: 'Lodash modular utilities' } }, + { package: { name: 'underscore', description: 'JavaScript utility library' } }, + ], + total: 2, + time: new Date().toISOString(), + }) + }) + + describe('selected packages display', () => { + it('renders selected packages as chips', async () => { + const packages = ref(['lodash', 'underscore']) + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: packages.value, + }, + }) + + expect(component.text()).toContain('lodash') + expect(component.text()).toContain('underscore') + }) + + it('renders package names as links', async () => { + const packages = ref(['lodash']) + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: packages.value, + }, + }) + + const link = component.find('a[href="/lodash"]') + expect(link.exists()).toBe(true) + }) + + it('renders remove button for each package', async () => { + const packages = ref(['lodash', 'underscore']) + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: packages.value, + }, + }) + + const removeButtons = component + .findAll('button') + .filter(b => b.find('.i-carbon\\:close').exists()) + expect(removeButtons.length).toBe(2) + }) + + it('emits update when remove button is clicked', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash', 'underscore'], + }, + }) + + const removeButton = component + .findAll('button') + .find(b => b.find('.i-carbon\\:close').exists()) + await removeButton!.trigger('click') + + const emitted = component.emitted('update:modelValue') + expect(emitted).toBeTruthy() + expect(emitted![0]![0]).toEqual(['underscore']) + }) + }) + + describe('search input', () => { + it('renders search input when under max packages', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash'], + max: 4, + }, + }) + + expect(component.find('input[type="text"]').exists()).toBe(true) + }) + + it('hides search input when at max packages', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['a', 'b', 'c', 'd'], + max: 4, + }, + }) + + expect(component.find('input[type="text"]').exists()).toBe(false) + }) + + it('shows different placeholder for first vs additional packages', async () => { + // Empty state + let component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + }, + }) + let input = component.find('input') + expect(input.attributes('placeholder')).toBeTruthy() + + // With packages + component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash'], + }, + }) + input = component.find('input') + expect(input.attributes('placeholder')).toBeTruthy() + }) + + it('has search icon', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + }, + }) + + expect(component.find('.i-carbon\\:search').exists()).toBe(true) + }) + }) + + describe('adding packages', () => { + it('adds package on Enter key', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + }, + }) + + const input = component.find('input') + await input.setValue('my-package') + await input.trigger('keydown', { key: 'Enter' }) + + const emitted = component.emitted('update:modelValue') + expect(emitted).toBeTruthy() + expect(emitted![0]![0]).toEqual(['my-package']) + }) + + it('clears input after adding package', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + }, + }) + + const input = component.find('input') + await input.setValue('my-package') + await input.trigger('keydown', { key: 'Enter' }) + + // Input should be cleared + expect((input.element as HTMLInputElement).value).toBe('') + }) + + it('does not add duplicate packages', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash'], + }, + }) + + const input = component.find('input') + await input.setValue('lodash') + await input.trigger('keydown', { key: 'Enter' }) + + const emitted = component.emitted('update:modelValue') + // Should not emit since lodash is already selected + expect(emitted).toBeFalsy() + }) + + it('respects max packages limit', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['a', 'b', 'c', 'd'], + max: 4, + }, + }) + + // Input should not be visible + expect(component.find('input').exists()).toBe(false) + }) + }) + + describe('hint text', () => { + it('shows packages selected count', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash', 'underscore'], + max: 4, + }, + }) + + expect(component.text()).toContain('2') + expect(component.text()).toContain('4') + }) + + it('shows add hint when less than 2 packages', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: ['lodash'], + max: 4, + }, + }) + + // Should have hint about adding more + expect(component.text().toLowerCase()).toContain('add') + }) + }) + + describe('max prop', () => { + it('defaults to 4 when not provided', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + }, + }) + + // Should show max of 4 in hint + expect(component.text()).toContain('4') + }) + + it('uses provided max value', async () => { + const component = await mountSuspended(PackageSelector, { + props: { + modelValue: [], + max: 3, + }, + }) + + expect(component.text()).toContain('3') + }) + }) +}) diff --git a/test/nuxt/composables/use-facet-selection.spec.ts b/test/nuxt/composables/use-facet-selection.spec.ts new file mode 100644 index 00000000..a547f616 --- /dev/null +++ b/test/nuxt/composables/use-facet-selection.spec.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { DEFAULT_FACETS, FACETS_BY_CATEGORY } from '#shared/types/comparison' + +// Mock useRouteQuery +const mockRouteQuery = ref('') +vi.mock('@vueuse/router', () => ({ + useRouteQuery: () => mockRouteQuery, +})) + +describe('useFacetSelection', () => { + beforeEach(() => { + mockRouteQuery.value = '' + }) + + it('returns DEFAULT_FACETS when no query param', () => { + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) + }) + + it('parses facets from query param', () => { + mockRouteQuery.value = 'downloads,types,license' + + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('types') + expect(selectedFacets.value).toContain('license') + }) + + it('filters out invalid facets from query', () => { + mockRouteQuery.value = 'downloads,invalidFacet,types' + + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('types') + expect(selectedFacets.value).not.toContain('invalidFacet') + }) + + it('filters out comingSoon facets from query', () => { + mockRouteQuery.value = 'downloads,totalDependencies,types' + + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('types') + expect(selectedFacets.value).not.toContain('totalDependencies') + }) + + it('falls back to DEFAULT_FACETS if all parsed facets are invalid', () => { + mockRouteQuery.value = 'invalidFacet1,invalidFacet2' + + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) + }) + + describe('isFacetSelected', () => { + it('returns true for selected facets', () => { + mockRouteQuery.value = 'downloads,types' + + const { isFacetSelected } = useFacetSelection() + + expect(isFacetSelected('downloads')).toBe(true) + expect(isFacetSelected('types')).toBe(true) + }) + + it('returns false for unselected facets', () => { + mockRouteQuery.value = 'downloads,types' + + const { isFacetSelected } = useFacetSelection() + + expect(isFacetSelected('license')).toBe(false) + expect(isFacetSelected('engines')).toBe(false) + }) + }) + + describe('toggleFacet', () => { + it('adds facet when not selected', () => { + mockRouteQuery.value = 'downloads' + + const { selectedFacets, toggleFacet } = useFacetSelection() + + toggleFacet('types') + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('types') + }) + + it('removes facet when selected', () => { + mockRouteQuery.value = 'downloads,types' + + const { selectedFacets, toggleFacet } = useFacetSelection() + + toggleFacet('types') + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).not.toContain('types') + }) + + it('does not remove last facet', () => { + mockRouteQuery.value = 'downloads' + + const { selectedFacets, toggleFacet } = useFacetSelection() + + toggleFacet('downloads') + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value.length).toBe(1) + }) + }) + + describe('selectCategory', () => { + it('selects all facets in a category', () => { + mockRouteQuery.value = 'downloads' + + const { selectedFacets, selectCategory } = useFacetSelection() + + selectCategory('performance') + + const performanceFacets = FACETS_BY_CATEGORY.performance.filter( + f => f !== 'totalDependencies', // comingSoon facet + ) + for (const facet of performanceFacets) { + expect(selectedFacets.value).toContain(facet) + } + }) + + it('preserves existing selections from other categories', () => { + mockRouteQuery.value = 'downloads,license' + + const { selectedFacets, selectCategory } = useFacetSelection() + + selectCategory('compatibility') + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('license') + }) + }) + + describe('deselectCategory', () => { + it('deselects all facets in a category', () => { + mockRouteQuery.value = '' + const { selectedFacets, deselectCategory } = useFacetSelection() + + deselectCategory('performance') + + const nonComingSoonPerformanceFacets = FACETS_BY_CATEGORY.performance.filter( + f => f !== 'totalDependencies', + ) + for (const facet of nonComingSoonPerformanceFacets) { + expect(selectedFacets.value).not.toContain(facet) + } + }) + + it('does not deselect if it would leave no facets', () => { + mockRouteQuery.value = 'packageSize,installSize' + + const { selectedFacets, deselectCategory } = useFacetSelection() + + deselectCategory('performance') + + // Should still have at least one facet + expect(selectedFacets.value.length).toBeGreaterThan(0) + }) + }) + + describe('selectAll', () => { + it('selects all default facets', () => { + mockRouteQuery.value = 'downloads' + + const { selectedFacets, selectAll } = useFacetSelection() + + selectAll() + + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) + }) + }) + + describe('deselectAll', () => { + it('keeps only the first default facet', () => { + mockRouteQuery.value = '' + + const { selectedFacets, deselectAll } = useFacetSelection() + + deselectAll() + + expect(selectedFacets.value).toHaveLength(1) + expect(selectedFacets.value[0]).toBe(DEFAULT_FACETS[0]) + }) + }) + + describe('isAllSelected', () => { + it('returns true when all facets selected', () => { + mockRouteQuery.value = '' + + const { isAllSelected } = useFacetSelection() + + expect(isAllSelected.value).toBe(true) + }) + + it('returns false when not all facets selected', () => { + mockRouteQuery.value = 'downloads,types' + + const { isAllSelected } = useFacetSelection() + + expect(isAllSelected.value).toBe(false) + }) + }) + + describe('isNoneSelected', () => { + it('returns true when only one facet selected', () => { + mockRouteQuery.value = 'downloads' + + const { isNoneSelected } = useFacetSelection() + + expect(isNoneSelected.value).toBe(true) + }) + + it('returns false when multiple facets selected', () => { + mockRouteQuery.value = 'downloads,types' + + const { isNoneSelected } = useFacetSelection() + + expect(isNoneSelected.value).toBe(false) + }) + }) + + describe('URL param behavior', () => { + it('clears URL param when selecting all defaults', () => { + mockRouteQuery.value = 'downloads,types' + + const { selectAll } = useFacetSelection() + + selectAll() + + // Should clear to empty string when matching defaults + expect(mockRouteQuery.value).toBe('') + }) + + it('sets URL param when selecting subset of facets', () => { + mockRouteQuery.value = '' + + const { selectedFacets } = useFacetSelection() + + selectedFacets.value = ['downloads', 'types'] + + expect(mockRouteQuery.value).toBe('downloads,types') + }) + }) + + describe('allFacets export', () => { + it('exports allFacets array', () => { + const { allFacets } = useFacetSelection() + + expect(Array.isArray(allFacets)).toBe(true) + expect(allFacets.length).toBeGreaterThan(0) + }) + + it('allFacets includes all facets including comingSoon', () => { + const { allFacets } = useFacetSelection() + + expect(allFacets).toContain('totalDependencies') + }) + }) + + describe('whitespace handling', () => { + it('trims whitespace from facet names in query', () => { + mockRouteQuery.value = ' downloads , types , license ' + + const { selectedFacets } = useFacetSelection() + + expect(selectedFacets.value).toContain('downloads') + expect(selectedFacets.value).toContain('types') + expect(selectedFacets.value).toContain('license') + }) + }) + + describe('duplicate handling', () => { + it('handles duplicate facets in query by deduplication via Set', () => { + // When adding facets, the code uses Set for deduplication + mockRouteQuery.value = 'downloads' + + const { selectedFacets, selectCategory } = useFacetSelection() + + // downloads is in health category, selecting health should dedupe + selectCategory('health') + + // Count occurrences of downloads + const downloadsCount = selectedFacets.value.filter(f => f === 'downloads').length + expect(downloadsCount).toBe(1) + }) + }) + + describe('multiple category operations', () => { + it('can select multiple categories', () => { + mockRouteQuery.value = 'downloads' + + const { selectedFacets, selectCategory } = useFacetSelection() + + selectCategory('performance') + selectCategory('security') + + // Should have facets from both categories plus original + expect(selectedFacets.value).toContain('packageSize') + expect(selectedFacets.value).toContain('license') + expect(selectedFacets.value).toContain('downloads') + }) + + it('can deselect multiple categories', () => { + mockRouteQuery.value = '' + + const { selectedFacets, deselectCategory } = useFacetSelection() + + deselectCategory('performance') + deselectCategory('health') + + // Should not have performance or health facets + expect(selectedFacets.value).not.toContain('packageSize') + expect(selectedFacets.value).not.toContain('downloads') + }) + }) +})