diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 4580671cd3..67590100ee 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -79,6 +79,10 @@ const footerSections = computed>(( name: t('shortcuts.compare'), href: '/compare', }, + { + name: t('footer.tools'), + href: '/tools', + }, { name: t('shortcuts.settings'), href: '/settings', diff --git a/app/components/DepsStats/DependencyList.vue b/app/components/DepsStats/DependencyList.vue new file mode 100644 index 0000000000..eb6251721a --- /dev/null +++ b/app/components/DepsStats/DependencyList.vue @@ -0,0 +1,284 @@ + + + diff --git a/app/components/DepsStats/DependencyStats.vue b/app/components/DepsStats/DependencyStats.vue new file mode 100644 index 0000000000..afa3491437 --- /dev/null +++ b/app/components/DepsStats/DependencyStats.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/components/DepsStats/DependencyStatsPanel.vue b/app/components/DepsStats/DependencyStatsPanel.vue new file mode 100644 index 0000000000..b99aac3a7e --- /dev/null +++ b/app/components/DepsStats/DependencyStatsPanel.vue @@ -0,0 +1,178 @@ + + + diff --git a/app/components/DepsStats/PackageJsonUpload.vue b/app/components/DepsStats/PackageJsonUpload.vue new file mode 100644 index 0000000000..04fb649980 --- /dev/null +++ b/app/components/DepsStats/PackageJsonUpload.vue @@ -0,0 +1,90 @@ + + + diff --git a/app/composables/npm/useDirectDependencyHealth.ts b/app/composables/npm/useDirectDependencyHealth.ts new file mode 100644 index 0000000000..a35b49e9e5 --- /dev/null +++ b/app/composables/npm/useDirectDependencyHealth.ts @@ -0,0 +1,74 @@ +import type { DirectDependencyHealthResult } from '#shared/types/dependency-analysis' +import { DIRECT_DEPS_HEALTH_MAX } from '#shared/utils/constants' + +const EMPTY_HEALTH: DirectDependencyHealthResult = { + vulnerable: {}, + deprecated: {}, +} + +/** Lazily fetch direct dependency health in display-order batches. */ +export function useDirectDependencyHealth( + dependencies: MaybeRefOrGetter | undefined>, + orderedNames: MaybeRefOrGetter, +) { + const health = shallowRef(EMPTY_HEALTH) + + const settled = new Set() + let generation = 0 + + watch( + () => toValue(dependencies), + () => { + generation++ + settled.clear() + health.value = EMPTY_HEALTH + }, + { immediate: true }, + ) + + async function requestHealth(name: string) { + if (!import.meta.client || settled.has(name)) return + + const deps = toValue(dependencies) + if (!deps?.[name]) return + + const ordered = toValue(orderedNames) + const startIndex = ordered.indexOf(name) + if (startIndex === -1) return + + const batchNames: string[] = [] + for (let i = startIndex; i < ordered.length; i++) { + const candidate = ordered[i]! + if (settled.has(candidate) || !deps[candidate]) continue + settled.add(candidate) + batchNames.push(candidate) + if (batchNames.length === DIRECT_DEPS_HEALTH_MAX) break + } + + const batch = Object.fromEntries(batchNames.map(candidate => [candidate, deps[candidate]!])) + const currentGeneration = generation + + try { + const result = await $fetch( + '/api/registry/direct-deps-health', + { + method: 'POST', + body: { dependencies: batch }, + }, + ) + + if (currentGeneration !== generation) return + + health.value = { + vulnerable: { ...health.value.vulnerable, ...result.vulnerable }, + deprecated: { ...health.value.deprecated, ...result.deprecated }, + } + } catch { + if (currentGeneration === generation) { + for (const candidate of batchNames) settled.delete(candidate) + } + } + } + + return { health, requestHealth } +} diff --git a/app/composables/useCommandPaletteGlobalCommands.ts b/app/composables/useCommandPaletteGlobalCommands.ts index 0ba7af3c6e..65096752b5 100644 --- a/app/composables/useCommandPaletteGlobalCommands.ts +++ b/app/composables/useCommandPaletteGlobalCommands.ts @@ -314,6 +314,29 @@ export function useCommandPaletteGlobalCommands() { activeLabel: activeLabel(route.name === 'compare', t('command_palette.here')), to: { name: 'compare' }, }, + { + id: 'tools', + group: 'navigation', + label: t('nav.tools'), + keywords: [t('footer.tools'), t('shortcuts.deps_stats')], + iconClass: 'i-lucide:wrench', + active: route.name === 'tools' || route.name === 'tools-deps-stats', + activeLabel: activeLabel( + route.name === 'tools' || route.name === 'tools-deps-stats', + t('command_palette.here'), + ), + to: { name: 'tools' }, + }, + { + id: 'deps-stats', + group: 'navigation', + label: t('tools.deps_stats.name'), + keywords: [t('shortcuts.deps_stats'), 'package.json', 'deps'], + iconClass: 'i-lucide:file-json', + active: route.name === 'tools-deps-stats', + activeLabel: activeLabel(route.name === 'tools-deps-stats', t('command_palette.here')), + to: { name: 'tools-deps-stats' }, + }, { id: 'settings', group: 'navigation', diff --git a/app/pages/tools/deps-stats.vue b/app/pages/tools/deps-stats.vue new file mode 100644 index 0000000000..35883fd9af --- /dev/null +++ b/app/pages/tools/deps-stats.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/pages/tools/index.vue b/app/pages/tools/index.vue new file mode 100644 index 0000000000..405e625794 --- /dev/null +++ b/app/pages/tools/index.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/utils/parse-package-json-deps.ts b/app/utils/parse-package-json-deps.ts new file mode 100644 index 0000000000..68fdb0a978 --- /dev/null +++ b/app/utils/parse-package-json-deps.ts @@ -0,0 +1,137 @@ +import { parsePackageSpec } from '#shared/utils/parse-package-param' + +export type DependencyCategory = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies' + +export interface PackageJsonDependency { + name: string + /** Version range from package.json */ + range: string + packageName: string + category: DependencyCategory + /** True when the range is not a registry package (file:, workspace:, git, etc.) */ + nonRegistry: boolean +} + +export interface ParsedPackageJson { + name?: string + version?: string + dependencies: PackageJsonDependency[] +} + +const DEPENDENCY_CATEGORIES: DependencyCategory[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] + +const NON_REGISTRY_PREFIXES = [ + 'file:', + 'link:', + 'workspace:', + 'portal:', + 'git+', + 'git:', + 'github:', + 'gist:', + 'bitbucket:', + 'gitlab:', + 'jsr:', + 'http:', + 'https:', +] + +export function isNonRegistryRange(range: string): boolean { + const trimmed = range.trim() + if (!trimmed) return true + if (trimmed.startsWith('.') || trimmed.startsWith('/')) return true + return NON_REGISTRY_PREFIXES.some(prefix => trimmed.toLowerCase().startsWith(prefix)) +} + +/** + * Resolve `npm:pkg@range` / `npm:@scope/pkg@range` aliases to a registry package name. + * Returns `null` when the alias cannot be parsed. + */ +export function resolveNpmAlias(range: string): { packageName: string; range: string } | null { + if (!range.startsWith('npm:')) return null + + const spec = range.slice('npm:'.length) + if (!spec) return null + + const { name, version } = parsePackageSpec(spec) + return { + packageName: name, + range: version || '*', + } +} + +function toDependency( + name: string, + range: string, + category: DependencyCategory, +): PackageJsonDependency { + const alias = resolveNpmAlias(range) + if (alias) { + return { + name, + range: alias.range, + packageName: alias.packageName, + category, + nonRegistry: isNonRegistryRange(alias.range), + } + } + + return { + name, + range, + packageName: name, + category, + nonRegistry: isNonRegistryRange(range), + } +} + +export function parsePackageJsonDependencies(raw: unknown): ParsedPackageJson { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid package.json: expected a JSON object') + } + + const pkg = raw as Record + const dependencies: PackageJsonDependency[] = [] + + for (const category of DEPENDENCY_CATEGORIES) { + const section = pkg[category] + if (!section || typeof section !== 'object' || Array.isArray(section)) continue + + for (const [name, range] of Object.entries(section as Record)) { + if (typeof range !== 'string') continue + dependencies.push(toDependency(name, range, category)) + } + } + + dependencies.sort((a, b) => { + const categoryOrder = + DEPENDENCY_CATEGORIES.indexOf(a.category) - DEPENDENCY_CATEGORIES.indexOf(b.category) + if (categoryOrder !== 0) return categoryOrder + return a.name.localeCompare(b.name) + }) + + return { + name: typeof pkg.name === 'string' ? pkg.name : undefined, + version: typeof pkg.version === 'string' ? pkg.version : undefined, + dependencies, + } +} + +export function parsePackageJsonText(text: string): ParsedPackageJson { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid package.json: could not parse JSON') + } + return parsePackageJsonDependencies(parsed) +} diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 9e0df4bbfe..d7ebfdc614 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -44,6 +44,8 @@ npmx.dev keeps package exploration views in routes or query parameters so you ca | ------------------- | --------------------------------- | | Search results | `/search?q=vue` | | Package comparison | `/compare?packages=react,preact` | +| Tools | `/tools` | +| Dependency stats | `/tools/deps-stats` | | Source file or line | `/package-code/vue/v/3.5.27/...` | | Generated docs | `/package-docs/ufo/v/1.6.3` | | Version diff | `/diff/vue/v/3.5.26...3.5.27` | diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..4731c3b1b8 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -24,6 +24,7 @@ "brand": "brand", "resources": "Resources", "features": "Features", + "tools": "tools", "other": "Other", "sponsored_by": "Sponsored by {list}" }, @@ -40,6 +41,7 @@ "show_kbd_hints": "Highlight keyboard hints", "settings": "Open settings", "compare": "Open compare", + "deps_stats": "Open dependency stats tool", "compare_from_package": "Open compare (prefilled with current package)", "changelog": "Open changelog", "navigate_results": "Navigate results", @@ -207,6 +209,7 @@ "popular_packages": "Popular packages", "settings": "settings", "compare": "compare", + "tools": "tools", "back": "back", "menu": "Menu", "mobile_menu": "Navigation menu", @@ -214,6 +217,46 @@ "links": "Links", "tap_to_search": "Tap to search" }, + "tools": { + "title": "tools", + "tagline": "utilities for exploring packages and project dependencies", + "meta_title": "Tools - npmx", + "meta_description": "Browse npmx tools for exploring packages and project dependencies", + "deps_stats": { + "name": "deps stats", + "description": "Upload a package.json and inspect stats for each dependency" + } + }, + "deps_stats": { + "title": "deps stats", + "meta_title": "Deps stats - npmx", + "meta_description": "Upload a package.json and explore stats for each dependency", + "upload": { + "section": "Package.json", + "label": "Upload package.json", + "hint": "Drop a package.json here, or choose a file. Only the dependency fields are read — nothing is uploaded to a server", + "clear": "Clear", + "invalid_package_json": "Invalid package.json" + }, + "dependencies": { + "title": "Dependencies", + "count": "No deps | 1 dep | {count} deps", + "filter_label": "Filter dependencies", + "filter_placeholder": "Filter dependencies...", + "empty": "No dependencies found in this package.json", + "no_matches": "No dependencies match your filter", + "non_registry": "Not an npm registry package" + }, + "stats": { + "empty_title": "Select a dependency", + "empty_description": "Pick a package from the list to see downloads, size, vulnerabilities, trends, and more", + "non_registry_title": "Local or remote dependency", + "non_registry_description": "This entry points to a file, workspace, git, or URL dependency, so registry stats are unavailable", + "declared_range": "Declared", + "resolved_version": "Latest", + "open_full_stats": "Open full stats" + } + }, "blog": { "title": "Blog", "heading": "blog", diff --git a/i18n/schema.json b/i18n/schema.json index b7793e498c..b579451edb 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -76,6 +76,9 @@ "features": { "type": "string" }, + "tools": { + "type": "string" + }, "other": { "type": "string" }, @@ -124,6 +127,9 @@ "compare": { "type": "string" }, + "deps_stats": { + "type": "string" + }, "compare_from_package": { "type": "string" }, @@ -625,6 +631,9 @@ "compare": { "type": "string" }, + "tools": { + "type": "string" + }, "back": { "type": "string" }, @@ -646,6 +655,126 @@ }, "additionalProperties": false }, + "tools": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "meta_title": { + "type": "string" + }, + "meta_description": { + "type": "string" + }, + "deps_stats": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "deps_stats": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "meta_title": { + "type": "string" + }, + "meta_description": { + "type": "string" + }, + "upload": { + "type": "object", + "properties": { + "section": { + "type": "string" + }, + "label": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "clear": { + "type": "string" + }, + "invalid_package_json": { + "type": "string" + } + }, + "additionalProperties": false + }, + "dependencies": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "count": { + "type": "string" + }, + "filter_label": { + "type": "string" + }, + "filter_placeholder": { + "type": "string" + }, + "empty": { + "type": "string" + }, + "no_matches": { + "type": "string" + }, + "non_registry": { + "type": "string" + } + }, + "additionalProperties": false + }, + "stats": { + "type": "object", + "properties": { + "empty_title": { + "type": "string" + }, + "empty_description": { + "type": "string" + }, + "non_registry_title": { + "type": "string" + }, + "non_registry_description": { + "type": "string" + }, + "declared_range": { + "type": "string" + }, + "resolved_version": { + "type": "string" + }, + "open_full_stats": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "blog": { "type": "object", "properties": { diff --git a/nuxt.config.ts b/nuxt.config.ts index 43b55b19cf..fe4095063f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -221,6 +221,8 @@ export default defineNuxtConfig({ '/blog/**': { prerender: true }, '/noodles/**': { prerender: true }, '/sponsors': { prerender: true }, + '/tools': { prerender: true }, + '/tools/deps-stats': { prerender: true }, // proxy for insights '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js', diff --git a/server/api/registry/direct-deps-health.post.ts b/server/api/registry/direct-deps-health.post.ts new file mode 100644 index 0000000000..ec00a9f772 --- /dev/null +++ b/server/api/registry/direct-deps-health.post.ts @@ -0,0 +1,15 @@ +import * as v from 'valibot' +import type { DirectDependencyHealthResult } from '#shared/types/dependency-analysis' +import { DirectDepsHealthBodySchema } from '#shared/schemas/dependency-analysis' +import { analyzeDirectDependencyHealth } from '#server/utils/dependency-analysis' + +/** + * POST /api/registry/direct-deps-health + * + * Resolve declared dependency ranges and report which packages are vulnerable + * or deprecated at the resolved version (direct packages only — no tree walk). + */ +export default defineEventHandler(async (event): Promise => { + const body = v.parse(DirectDepsHealthBodySchema, await readBody(event)) + return await analyzeDirectDependencyHealth(body.dependencies) +}) diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 738612c2a7..0f3c1bf915 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -31,6 +31,7 @@ const pages = [ '/privacy', '/search', '/settings', + '/tools', '/translation-status', '/recharging', ] diff --git a/server/utils/dependency-analysis.ts b/server/utils/dependency-analysis.ts index 0b42ee636e..8d6d142d9e 100644 --- a/server/utils/dependency-analysis.ts +++ b/server/utils/dependency-analysis.ts @@ -8,11 +8,12 @@ import type { PackageVulnerabilityInfo, VulnerabilityTreeResult, DeprecatedPackageInfo, + DirectDependencyHealthResult, OsvAffected, OsvRange, } from '#shared/types/dependency-analysis' import { mapWithConcurrency } from '#shared/utils/async' -import { resolveDependencyTree } from './dependency-resolver' +import { resolveDependencyTree, resolveVersion } from './dependency-resolver' import { compare, isGreaterOrEqual, isLess } from 'verkit' /** Maximum concurrent requests for fetching vulnerability details */ @@ -359,3 +360,82 @@ export const analyzeDependencyTree = defineCachedFunction( getKey: (name: string, version: string) => `v2:${name}@${version}`, }, ) + +const DIRECT_DEPS_RESOLVE_CONCURRENCY = 20 + +export async function analyzeDirectDependencyHealth( + dependencies: Record, +): Promise { + const entries = Object.entries(dependencies) + const resolved = await mapWithConcurrency( + entries, + async ([name, range]) => { + try { + const packument = await fetchNpmPackage(name) + const versions = Object.keys(packument.versions) + const version = packument['dist-tags']?.[range] ?? resolveVersion(range, versions) + if (!version) return null + + const versionData = packument.versions[version] + if (!versionData) return null + + return { + name, + version, + deprecated: versionData?.deprecated, + } + } catch { + return null + } + }, + DIRECT_DEPS_RESOLVE_CONCURRENCY, + ) + + const packages: PackageQueryInfo[] = [] + const deprecated: DirectDependencyHealthResult['deprecated'] = {} + + for (const pkg of resolved) { + if (!pkg) continue + packages.push({ + name: pkg.name, + version: pkg.version, + depth: 'direct', + path: [], + }) + if (pkg.deprecated) { + deprecated[pkg.name] = { + name: pkg.name, + version: pkg.version, + message: pkg.deprecated, + } + } + } + + const vulnerable: DirectDependencyHealthResult['vulnerable'] = {} + + if (packages.length === 0) { + return { vulnerable, deprecated } + } + + const { vulnerableIndices, failed: batchFailed } = await queryOsvBatch(packages) + if (batchFailed || vulnerableIndices.length === 0) { + return { vulnerable, deprecated } + } + + const detailResults = await mapWithConcurrency( + vulnerableIndices, + i => queryOsvDetails(packages[i]!), + OSV_DETAIL_CONCURRENCY, + ) + + for (const result of detailResults) { + if (!result) continue + vulnerable[result.name] = { + name: result.name, + version: result.version, + counts: result.counts, + } + } + + return { vulnerable, deprecated } +} diff --git a/shared/schemas/dependency-analysis.ts b/shared/schemas/dependency-analysis.ts new file mode 100644 index 0000000000..1a65d86197 --- /dev/null +++ b/shared/schemas/dependency-analysis.ts @@ -0,0 +1,13 @@ +import * as v from 'valibot' +import { PackageNameSchema } from './package' +import { DIRECT_DEPS_HEALTH_MAX } from '#shared/utils/constants' + +export const DirectDepsHealthBodySchema = v.object({ + dependencies: v.pipe( + v.record(PackageNameSchema, v.string()), + v.check( + deps => Object.keys(deps).length <= DIRECT_DEPS_HEALTH_MAX, + `Too many dependencies (max ${DIRECT_DEPS_HEALTH_MAX})`, + ), + ), +}) diff --git a/shared/types/dependency-analysis.ts b/shared/types/dependency-analysis.ts index a733c302e5..31adbe065b 100644 --- a/shared/types/dependency-analysis.ts +++ b/shared/types/dependency-analysis.ts @@ -19,6 +19,7 @@ export type OsvSeverityLevel = (typeof SEVERITY_LEVELS)[number] | 'unknown' * Counts by severity level */ export type SeverityCounts = Record<(typeof SEVERITY_LEVELS)[number], number> +export type VulnerabilityCounts = SeverityCounts & { total: number } /** * CVSS severity information from OSV @@ -140,7 +141,7 @@ export interface PackageVulnerabilities { package: string version: string vulnerabilities: VulnerabilitySummary[] - counts: SeverityCounts & { total: number } + counts: VulnerabilityCounts } /** Depth in dependency tree */ @@ -157,13 +158,7 @@ export interface PackageVulnerabilityInfo { /** Dependency path from root package */ path: string[] vulnerabilities: VulnerabilitySummary[] - counts: { - total: number - critical: number - high: number - moderate: number - low: number - } + counts: VulnerabilityCounts } /** @@ -197,11 +192,31 @@ export interface VulnerabilityTreeResult { /** Number of packages that could not be checked (OSV query failed) */ failedQueries: number /** Aggregated counts across all packages */ - totalCounts: { - total: number - critical: number - high: number - moderate: number - low: number - } + totalCounts: VulnerabilityCounts +} + +/** + * Vulnerability info for a direct dependency (no tree depth/path) + */ +export interface DirectVulnerableDependency { + name: string + version: string + counts: VulnerabilityCounts +} + +/** + * Deprecated info for a direct dependency + */ +export interface DirectDeprecatedDependency { + name: string + version: string + message: string +} + +/** + * Health check result for a set of direct dependencies (resolved ranges only) + */ +export interface DirectDependencyHealthResult { + vulnerable: Record + deprecated: Record } diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index bf8030c9ef..f362bbdb4b 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -7,6 +7,9 @@ export const CACHE_MAX_AGE_ONE_HOUR = 60 * 60 export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24 export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365 +/** Max packages per direct-deps-health API request (client batches to this size) */ +export const DIRECT_DEPS_HEALTH_MAX = 50 + // API Strings export const NPMX_SITE = 'https://npmx.dev' export const NPMX_DOCS_SITE = 'https://docs.npmx.dev' diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index e1b79d1656..ec296a28a3 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -196,6 +196,10 @@ import { CompareReplacementSuggestion, DateTime, DependencyPathPopup, + DepsStatsDependencyList, + DepsStatsDependencyStats, + DepsStatsDependencyStatsPanel, + DepsStatsPackageJsonUpload, FilterChips, FilterPanel, HeaderAccountMenu, @@ -2536,6 +2540,132 @@ describe('component accessibility audits', () => { }) }) + describe('DepsStatsPackageJsonUpload', () => { + it('should have no accessibility violations in empty state', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with an error', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload, { + props: { error: 'Invalid package.json' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with a selected file', async () => { + const component = await mountSuspended(DepsStatsPackageJsonUpload, { + props: { fileName: 'package.json' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyList', () => { + it('should have no accessibility violations when empty', async () => { + const component = await mountSuspended(DepsStatsDependencyList, { + props: { + dependencies: [], + selectedName: null, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with dependencies', async () => { + const component = await mountSuspended(DepsStatsDependencyList, { + props: { + dependencies: [ + { + name: 'vue', + range: '^3.5.0', + packageName: 'vue', + category: 'dependencies', + nonRegistry: false, + }, + { + name: 'vitest', + range: '^3.0.0', + packageName: 'vitest', + category: 'devDependencies', + nonRegistry: false, + }, + { + name: 'local-pkg', + range: 'workspace:*', + packageName: 'local-pkg', + category: 'dependencies', + nonRegistry: true, + }, + { + name: 'alias-pkg', + range: '^1.0.0', + packageName: 'real-pkg', + category: 'dependencies', + nonRegistry: false, + }, + ], + selectedName: 'vue', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyStats', () => { + it('should have no accessibility violations when empty', async () => { + const component = await mountSuspended(DepsStatsDependencyStats, { + props: { dependency: null }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations for a non-registry dependency', async () => { + const component = await mountSuspended(DepsStatsDependencyStats, { + props: { + dependency: { + name: 'local-pkg', + range: 'workspace:*', + packageName: 'local-pkg', + category: 'dependencies', + nonRegistry: true, + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DepsStatsDependencyStatsPanel', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(DepsStatsDependencyStatsPanel, { + props: { + packageName: 'vue', + declaredRange: '^3.5.0', + }, + global: { + stubs: { + PackageTrendsChart: { + template: '
', + }, + PackageVersionDistribution: { + template: '
', + }, + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + // Compare feature components describe('CompareFacetSelector', () => { it('should have no accessibility violations', async () => { diff --git a/test/nuxt/composables/use-direct-dependency-health.spec.ts b/test/nuxt/composables/use-direct-dependency-health.spec.ts new file mode 100644 index 0000000000..2e59a9b626 --- /dev/null +++ b/test/nuxt/composables/use-direct-dependency-health.spec.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref, type EffectScope } from 'vue' +import type { DirectDependencyHealthResult } from '#shared/types/dependency-analysis' +import { DIRECT_DEPS_HEALTH_MAX } from '#shared/utils/constants' +import { useDirectDependencyHealth } from '~/composables/npm/useDirectDependencyHealth' + +const EMPTY_HEALTH: DirectDependencyHealthResult = { + vulnerable: {}, + deprecated: {}, +} + +describe('useDirectDependencyHealth', () => { + let scope: EffectScope + let fetchMock: ReturnType + + beforeEach(() => { + scope = effectScope() + fetchMock = vi.fn().mockResolvedValue(EMPTY_HEALTH) + vi.stubGlobal('$fetch', fetchMock) + }) + + afterEach(() => { + scope.stop() + vi.unstubAllGlobals() + }) + + it('loads dependencies in display-order batches', async () => { + const names = Array.from({ length: DIRECT_DEPS_HEALTH_MAX + 10 }, (_, index) => `pkg-${index}`) + const dependencies = Object.fromEntries(names.map(name => [name, '^1.0.0'])) + const result = scope.run(() => useDirectDependencyHealth(dependencies, names))! + + await result.requestHealth(names[0]!) + + const firstBatch = fetchMock.mock.calls[0]?.[1]?.body.dependencies + expect(Object.keys(firstBatch)).toEqual(names.slice(0, DIRECT_DEPS_HEALTH_MAX)) + + await result.requestHealth(names[1]!) + expect(fetchMock).toHaveBeenCalledTimes(1) + + await result.requestHealth(names[DIRECT_DEPS_HEALTH_MAX]!) + const secondBatch = fetchMock.mock.calls[1]?.[1]?.body.dependencies + expect(Object.keys(secondBatch)).toEqual(names.slice(DIRECT_DEPS_HEALTH_MAX)) + }) + + it('does not let a stale failed request clear current settled state', async () => { + let rejectStaleRequest!: (reason: Error) => void + fetchMock + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectStaleRequest = reject + }), + ) + .mockResolvedValueOnce(EMPTY_HEALTH) + + const dependencies = ref({ pkg: '^1.0.0' }) + const result = scope.run(() => useDirectDependencyHealth(dependencies, ['pkg']))! + const staleRequest = result.requestHealth('pkg') + + dependencies.value = { pkg: '^2.0.0' } + await nextTick() + await result.requestHealth('pkg') + + rejectStaleRequest(new Error('stale request failed')) + await staleRequest + await result.requestHealth('pkg') + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/unit/app/utils/parse-package-json-deps.spec.ts b/test/unit/app/utils/parse-package-json-deps.spec.ts new file mode 100644 index 0000000000..84f33a9dc2 --- /dev/null +++ b/test/unit/app/utils/parse-package-json-deps.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isNonRegistryRange, + parsePackageJsonDependencies, + parsePackageJsonText, + resolveNpmAlias, +} from '~/utils/parse-package-json-deps' + +describe('parse-package-json-deps', () => { + describe('isNonRegistryRange', () => { + it('detects local and remote protocols', () => { + expect(isNonRegistryRange('file:../local')).toBe(true) + expect(isNonRegistryRange('workspace:*')).toBe(true) + expect(isNonRegistryRange('link:./pkg')).toBe(true) + expect(isNonRegistryRange('git+https://github.com/org/repo.git')).toBe(true) + expect(isNonRegistryRange('https://example.com/pkg.tgz')).toBe(true) + expect(isNonRegistryRange('^1.2.3')).toBe(false) + expect(isNonRegistryRange('*')).toBe(false) + }) + }) + + describe('resolveNpmAlias', () => { + it('resolves unscoped and scoped npm aliases', () => { + expect(resolveNpmAlias('npm:lodash@4.17.21')).toEqual({ + packageName: 'lodash', + range: '4.17.21', + }) + expect(resolveNpmAlias('npm:@scope/pkg@^1.0.0')).toEqual({ + packageName: '@scope/pkg', + range: '^1.0.0', + }) + expect(resolveNpmAlias('^1.0.0')).toBeNull() + }) + }) + + describe('parsePackageJsonDependencies', () => { + it('parses dependency groups in order', () => { + const result = parsePackageJsonDependencies({ + name: 'demo', + version: '1.0.0', + dependencies: { vue: '^3.5.0', react: '^19.0.0' }, + devDependencies: { vitest: '^3.0.0' }, + peerDependencies: { typescript: '>=5' }, + optionalDependencies: { fsevents: '^2.3.0' }, + }) + + expect(result.name).toBe('demo') + expect(result.dependencies.map(d => `${d.category}:${d.name}`)).toEqual([ + 'dependencies:react', + 'dependencies:vue', + 'devDependencies:vitest', + 'peerDependencies:typescript', + 'optionalDependencies:fsevents', + ]) + }) + + it('resolves npm aliases and marks non-registry ranges', () => { + const result = parsePackageJsonDependencies({ + dependencies: { + alias: 'npm:lodash@^4.17.21', + local: 'file:../pkg', + }, + }) + + expect(result.dependencies).toEqual([ + { + name: 'alias', + range: '^4.17.21', + packageName: 'lodash', + category: 'dependencies', + nonRegistry: false, + }, + { + name: 'local', + range: 'file:../pkg', + packageName: 'local', + category: 'dependencies', + nonRegistry: true, + }, + ]) + }) + + it('throws for non-object JSON roots', () => { + expect(() => parsePackageJsonDependencies([])).toThrow(/expected a JSON object/) + expect(() => parsePackageJsonText('{')).toThrow(/could not parse JSON/) + }) + }) +})