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

fix: show correct dependencies count (#787)

Co-authored-by: shamilkotta <shamilkotta99@gmail.com>

authored by

Roman
shamilkotta
and committed by
GitHub
32267641 d54ac07d

+86 -64
+43 -36
app/composables/usePackageComparison.ts
··· 8 8 import { encodePackageName } from '#shared/utils/npm' 9 9 import type { PackageAnalysisResponse } from './usePackageAnalysis' 10 10 import { isBinaryOnlyPackage } from '#shared/utils/binary-detection' 11 + import { formatBytes } from '~/utils/formatters' 12 + import { getDependencyCount } from '~/utils/npm/dependency-count' 11 13 12 14 export interface PackageComparisonData { 13 15 package: ComparisonPackage 14 16 downloads?: number 15 17 /** Package's own unpacked size (from dist.unpackedSize) */ 16 18 packageSize?: number 19 + /** Number of direct dependencies */ 20 + directDeps: number | null 17 21 /** Install size data (fetched lazily) */ 18 22 installSize?: { 19 23 selfSize: number 20 24 totalSize: number 25 + /** Total dependency count */ 21 26 dependencyCount: number 22 27 } 23 28 analysis?: PackageAnalysisResponse ··· 139 144 }, 140 145 downloads: downloads?.downloads, 141 146 packageSize, 147 + directDeps: versionData ? getDependencyCount(versionData) : null, 142 148 installSize: undefined, // Will be filled in second pass 143 149 analysis: analysis ?? undefined, 144 150 vulnerabilities: { ··· 230 236 function isFacetLoading(facet: ComparisonFacet): boolean { 231 237 if (!installSizeLoading.value) return false 232 238 // These facets depend on install-size API 233 - return facet === 'installSize' || facet === 'dependencies' 239 + return facet === 'installSize' || facet === 'totalDependencies' 234 240 } 235 241 236 242 // Check if a specific column (package) is loading ··· 255 261 t: (key: string, params?: Record<string, unknown>) => string, 256 262 ): FacetValue | null { 257 263 switch (facet) { 258 - case 'downloads': 264 + case 'downloads': { 259 265 if (data.downloads === undefined) return null 260 266 return { 261 267 raw: data.downloads, 262 268 display: formatCompactNumber(data.downloads), 263 269 status: 'neutral', 264 270 } 265 - 266 - case 'packageSize': 271 + } 272 + case 'packageSize': { 267 273 if (!data.packageSize) return null 268 274 return { 269 275 raw: data.packageSize, 270 276 display: formatBytes(data.packageSize), 271 277 status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral', 272 278 } 273 - 274 - case 'installSize': 279 + } 280 + case 'installSize': { 275 281 if (!data.installSize) return null 276 282 return { 277 283 raw: data.installSize.totalSize, 278 284 display: formatBytes(data.installSize.totalSize), 279 285 status: data.installSize.totalSize > 50 * 1024 * 1024 ? 'warning' : 'neutral', 280 286 } 281 - 282 - case 'moduleFormat': 287 + } 288 + case 'moduleFormat': { 283 289 if (!data.analysis) return null 284 290 const format = data.analysis.moduleFormat 285 291 return { ··· 287 293 display: format === 'dual' ? 'ESM + CJS' : format.toUpperCase(), 288 294 status: format === 'esm' || format === 'dual' ? 'good' : 'neutral', 289 295 } 290 - 291 - case 'types': 296 + } 297 + case 'types': { 292 298 if (data.isBinaryOnly) { 293 299 return { 294 300 raw: 'binary', ··· 309 315 : t('compare.facets.values.types_none'), 310 316 status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad', 311 317 } 312 - 313 - case 'engines': 318 + } 319 + case 'engines': { 314 320 const engines = data.metadata?.engines 315 321 if (!engines?.node) { 316 322 return { raw: null, display: t('compare.facets.values.any'), status: 'neutral' } ··· 320 326 display: `Node ${engines.node}`, 321 327 status: 'neutral', 322 328 } 323 - 324 - case 'vulnerabilities': 329 + } 330 + case 'vulnerabilities': { 325 331 if (!data.vulnerabilities) return null 326 332 const count = data.vulnerabilities.count 327 333 const sev = data.vulnerabilities.severity ··· 337 343 }), 338 344 status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning', 339 345 } 340 - 341 - case 'lastUpdated': 346 + } 347 + case 'lastUpdated': { 342 348 if (!data.metadata?.lastUpdated) return null 343 349 const date = new Date(data.metadata.lastUpdated) 344 350 return { ··· 347 353 status: isStale(date) ? 'warning' : 'neutral', 348 354 type: 'date', 349 355 } 350 - 351 - case 'license': 356 + } 357 + case 'license': { 352 358 const license = data.metadata?.license 353 359 if (!license) { 354 360 return { raw: null, display: t('compare.facets.values.unknown'), status: 'warning' } ··· 358 364 display: license, 359 365 status: 'neutral', 360 366 } 361 - 362 - case 'dependencies': 363 - if (!data.installSize) return null 364 - const depCount = data.installSize.dependencyCount 367 + } 368 + case 'dependencies': { 369 + const depCount = data.directDeps 370 + if (depCount === null) return null 365 371 return { 366 372 raw: depCount, 367 373 display: String(depCount), 368 - status: depCount > 50 ? 'warning' : 'neutral', 374 + status: depCount > 10 ? 'warning' : 'neutral', 369 375 } 370 - 371 - case 'deprecated': 376 + } 377 + case 'deprecated': { 372 378 const isDeprecated = !!data.metadata?.deprecated 373 379 return { 374 380 raw: isDeprecated, ··· 377 383 : t('compare.facets.values.not_deprecated'), 378 384 status: isDeprecated ? 'bad' : 'good', 379 385 } 380 - 386 + } 381 387 // Coming soon facets 382 - case 'totalDependencies': 388 + case 'totalDependencies': { 389 + if (!data.installSize) return null 390 + const totalDepCount = data.installSize.dependencyCount 391 + return { 392 + raw: totalDepCount, 393 + display: String(totalDepCount), 394 + status: totalDepCount > 50 ? 'warning' : 'neutral', 395 + } 396 + } 397 + default: { 383 398 return null 384 - 385 - default: 386 - return null 399 + } 387 400 } 388 - } 389 - 390 - function formatBytes(bytes: number): string { 391 - if (bytes < 1024) return `${bytes} B` 392 - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` 393 - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 394 401 } 395 402 396 403 function isStale(date: Date): boolean {
+1 -5
app/pages/package/[...package].vue
··· 11 11 import { areUrlsEquivalent } from '#shared/utils/url' 12 12 import { isEditableElement } from '~/utils/input' 13 13 import { formatBytes } from '~/utils/formatters' 14 + import { getDependencyCount } from '~/utils/npm/dependency-count' 14 15 import { NuxtLink } from '#components' 15 16 import { useModal } from '~/composables/useModal' 16 17 import { useAtproto } from '~/composables/atproto/useAtproto' ··· 298 299 .replace(/\.git$/, '') 299 300 .replace(/^ssh:\/\/git@github\.com/, 'https://github.com') 300 301 .replace(/^git@github\.com:/, 'https://github.com/') 301 - } 302 - 303 - function getDependencyCount(version: PackumentVersion | null): number { 304 - if (!version?.dependencies) return 0 305 - return Object.keys(version.dependencies).length 306 302 } 307 303 308 304 // Check if a version has provenance/attestations
+6
app/utils/npm/dependency-count.ts
··· 1 + import type { PackumentVersion } from '#shared/types' 2 + 3 + export function getDependencyCount(version: PackumentVersion | null): number { 4 + if (!version?.dependencies) return 0 5 + return Object.keys(version.dependencies).length 6 + }
+6 -1
shared/types/comparison.ts
··· 41 41 }, 42 42 totalDependencies: { 43 43 category: 'performance', 44 - comingSoon: true, 45 44 }, 46 45 // Health 47 46 downloads: { ··· 111 110 version: string 112 111 description?: string 113 112 } 113 + 114 + // ComingSoon tests run only when FACET_INFO has at least one comingSoon facet 115 + export const comingSoonFacets = (Object.keys(FACET_INFO) as ComparisonFacet[]).filter( 116 + f => FACET_INFO[f].comingSoon, 117 + ) 118 + export const hasComingSoonFacets = comingSoonFacets.length > 0
+21 -12
test/nuxt/components/compare/FacetSelector.spec.ts
··· 1 1 import type { ComparisonFacet } from '#shared/types/comparison' 2 - import { CATEGORY_ORDER, FACET_INFO, FACETS_BY_CATEGORY } from '#shared/types/comparison' 2 + import { 3 + CATEGORY_ORDER, 4 + FACET_INFO, 5 + FACETS_BY_CATEGORY, 6 + comingSoonFacets, 7 + hasComingSoonFacets, 8 + } from '#shared/types/comparison' 3 9 import FacetSelector from '~/components/Compare/FacetSelector.vue' 4 10 import { beforeEach, describe, expect, it, vi } from 'vitest' 5 11 import { computed, ref } from 'vue' ··· 21 27 license: { label: 'License', description: 'Package license' }, 22 28 dependencies: { label: 'Direct Deps', description: 'Number of direct dependencies' }, 23 29 totalDependencies: { 24 - label: 'Total Deps', 30 + label: 'Total deps', 25 31 description: 'Total number of dependencies including transitive', 26 32 }, 27 33 deprecated: { label: 'Deprecated?', description: 'Whether the package is deprecated' }, ··· 33 39 compatibility: 'Compatibility', 34 40 security: 'Security & Compliance', 35 41 } 42 + 43 + const comingSoonFacetId = comingSoonFacets[0] 44 + const comingSoonFacetLabel = hasComingSoonFacets 45 + ? (facetLabels[comingSoonFacetId!]?.label ?? comingSoonFacetId) 46 + : '' 36 47 37 48 // Helper to build facet info with labels 38 49 function buildFacetInfo(facet: ComparisonFacet) { ··· 174 185 }) 175 186 }) 176 187 177 - describe('comingSoon facets', () => { 188 + describe.runIf(hasComingSoonFacets)('comingSoon facets', () => { 178 189 it('disables comingSoon facets', async () => { 179 190 const component = await mountSuspended(FacetSelector) 180 191 181 192 // totalDependencies is marked as comingSoon 182 193 const buttons = component.findAll('button') 183 - const comingSoonButton = buttons.find(b => b.text().includes('Total Deps')) 194 + const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel)) 184 195 185 196 expect(comingSoonButton?.attributes('disabled')).toBeDefined() 186 197 }) ··· 196 207 197 208 // Find the comingSoon button 198 209 const buttons = component.findAll('button') 199 - const comingSoonButton = buttons.find(b => b.text().includes('Total Deps')) 210 + const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel)) 200 211 201 212 // Should not have checkmark or add icon 202 213 expect(comingSoonButton?.find('.i-carbon\\:checkmark').exists()).toBe(false) ··· 207 218 const component = await mountSuspended(FacetSelector) 208 219 209 220 const buttons = component.findAll('button') 210 - const comingSoonButton = buttons.find(b => b.text().includes('Total Deps')) 221 + const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel)) 211 222 await comingSoonButton?.trigger('click') 212 223 213 224 // toggleFacet should not have been called with totalDependencies 214 - expect(mockToggleFacet).not.toHaveBeenCalledWith('totalDependencies') 225 + expect(mockToggleFacet).not.toHaveBeenCalledWith(comingSoonFacetId) 215 226 }) 216 227 }) 217 228 ··· 242 253 243 254 it('disables all button when all facets in category are selected', async () => { 244 255 // Select all performance facets 245 - const performanceFacets = FACETS_BY_CATEGORY.performance.filter( 256 + const performanceFacets: (string | ComparisonFacet)[] = FACETS_BY_CATEGORY.performance.filter( 246 257 f => !FACET_INFO[f].comingSoon, 247 258 ) 248 259 mockSelectedFacets.value = performanceFacets 249 - mockIsFacetSelected.mockImplementation((f: string) => 250 - performanceFacets.includes(f as ComparisonFacet), 251 - ) 260 + mockIsFacetSelected.mockImplementation((f: string) => performanceFacets.includes(f)) 252 261 253 262 const component = await mountSuspended(FacetSelector) 254 263 ··· 281 290 expect(component.find('.bg-bg-muted').exists()).toBe(true) 282 291 }) 283 292 284 - it('applies cursor-not-allowed to comingSoon facets', async () => { 293 + it.runIf(hasComingSoonFacets)('applies cursor-not-allowed to comingSoon facets', async () => { 285 294 const component = await mountSuspended(FacetSelector) 286 295 287 296 expect(component.find('.cursor-not-allowed').exists()).toBe(true)
+9 -10
test/nuxt/composables/use-facet-selection.spec.ts
··· 2 2 import { mountSuspended } from '@nuxt/test-utils/runtime' 3 3 import { ref } from 'vue' 4 4 import type { ComparisonFacet } from '#shared/types/comparison' 5 - import { DEFAULT_FACETS, FACETS_BY_CATEGORY } from '#shared/types/comparison' 5 + import { 6 + DEFAULT_FACETS, 7 + FACET_INFO, 8 + FACETS_BY_CATEGORY, 9 + hasComingSoonFacets, 10 + } from '#shared/types/comparison' 6 11 import type { FacetInfoWithLabels } from '~/composables/useFacetSelection' 7 12 8 13 // Mock useRouteQuery - needs to be outside of the helper to persist across calls ··· 113 118 expect(isFacetSelected('types')).toBe(true) 114 119 }) 115 120 116 - it('filters out comingSoon facets from query', async () => { 121 + it.runIf(hasComingSoonFacets)('filters out comingSoon facets from query', async () => { 117 122 mockRouteQuery.value = 'downloads,totalDependencies,types' 118 123 119 124 const { isFacetSelected } = await useFacetSelectionInComponent() ··· 225 230 selectCategory('performance') 226 231 227 232 const performanceFacets = FACETS_BY_CATEGORY.performance.filter( 228 - f => f !== 'totalDependencies', // comingSoon facet 233 + f => !FACET_INFO[f].comingSoon, // comingSoon facet 229 234 ) 230 235 for (const facet of performanceFacets) { 231 236 expect(isFacetSelected(facet)).toBe(true) ··· 252 257 deselectCategory('performance') 253 258 254 259 const nonComingSoonPerformanceFacets = FACETS_BY_CATEGORY.performance.filter( 255 - f => f !== 'totalDependencies', 260 + f => !FACET_INFO[f].comingSoon, 256 261 ) 257 262 for (const facet of nonComingSoonPerformanceFacets) { 258 263 expect(isFacetSelected(facet)).toBe(false) ··· 368 373 369 374 expect(Array.isArray(allFacets)).toBe(true) 370 375 expect(allFacets.length).toBeGreaterThan(0) 371 - }) 372 - 373 - it('allFacets includes all facets including comingSoon', async () => { 374 - const { allFacets } = await useFacetSelectionInComponent() 375 - 376 - expect(allFacets).toContain('totalDependencies') 377 376 }) 378 377 }) 379 378