From af8d66ca392b2cb97afa25afe0979ae1de1f87bc Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Thu, 19 Mar 2026 16:44:35 +0200 Subject: [PATCH 1/2] refactor: prioritize npm registry with jsdelivr fallback for exact versions Refactors the package version fetching strategy to use npm registry as the primary source, with jsdelivr serving as a fallback for exact-version manifest lookups and transient failures. Removes persistent caching in favor of fresh-by-default requests with in-flight deduplication to prevent duplicate network calls. The changelog fetcher now accepts an optional version parameter to resolve exact manifests from jsdelivr for pinned dependencies. --- README.md | 3 +- src/config/constants.ts | 1 - src/core/package-detector.ts | 31 +- src/interactive-ui.ts | 26 +- src/services/changelog-fetcher.ts | 112 +++-- src/services/index.ts | 2 - src/services/jsdelivr-registry.ts | 291 ++++--------- src/services/npm-registry.ts | 115 +++-- test/integration/services.test.ts | 81 +--- test/unit/services/changelog-fetcher.test.ts | 310 +++++++++----- .../jsdelivr-registry.retries.test.ts | 392 +++--------------- test/unit/services/jsdelivr-registry.test.ts | 156 +------ test/unit/services/npm-registry.test.ts | 247 +++++++---- 13 files changed, 729 insertions(+), 1038 deletions(-) diff --git a/README.md b/README.md index 5f9ea51..28818bb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,6 @@ inup [options] -i, --ignore Ignore packages (comma-separated, glob supported) --max-depth Maximum scan depth for package discovery (default: 10) --package-manager Force package manager (npm, yarn, pnpm, bun) ---no-cache Bypass cached package metadata and fetch fresh registry data --debug Write verbose debug logs ``` @@ -61,7 +60,7 @@ inup [options] We don't track anything. Ever. -The only network requests made are to the npm registry and jsDelivr CDN to fetch package version data. That's it. +Version checks and package metadata are fetched from the npm registry. When needed for immutable exact-version manifests, inup may also fetch a pinned `package.json` from jsDelivr. Weekly download counts come from the npm downloads API. ## 📄 License diff --git a/src/config/constants.ts b/src/config/constants.ts index 29563e6..1305a28 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -7,4 +7,3 @@ export const REQUEST_TIMEOUT = 60000 // 60 seconds in milliseconds export const JSDELIVR_RETRY_TIMEOUTS = [2000, 3500] // short retry budget to keep fallback fast export const JSDELIVR_RETRY_DELAYS = [150] // tiny backoff between jsDelivr retries in ms export const JSDELIVR_POOL_TIMEOUT = 60000 // keep-alive/connect lifecycle should be looser than per-request timeouts -export const DEFAULT_REGISTRY: 'jsdelivr' | 'npm' = 'jsdelivr' diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index aa8004e..f3156a7 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -7,8 +7,8 @@ import { collectAllDependenciesAsync, findClosestMinorVersion, } from '../utils' -import { getAllPackageDataFromJsdelivr, getAllPackageData } from '../services' -import { DEFAULT_REGISTRY, isPackageIgnored } from '../config' +import { getAllPackageData } from '../services' +import { isPackageIgnored } from '../config' import { ConsoleUtils } from '../ui/utils' import { debugLog } from '../utils' @@ -100,33 +100,22 @@ export class PackageDetector { `${packageNames.length} unique packages to check, ${ignoredCount} ignored` ) - // Step 4: Fetch all package data in one call per package - // Create a map of package names to their current versions for major version optimization const currentVersions = new Map() for (const dep of allDeps) { - // Use the first occurrence of each package's version if (!currentVersions.has(dep.name)) { currentVersions.set(dep.name, dep.version) } } const tFetch = Date.now() - debugLog.info('PackageDetector', `fetching version data via ${DEFAULT_REGISTRY}`) - const allPackageData = - DEFAULT_REGISTRY === 'jsdelivr' - ? await getAllPackageDataFromJsdelivr( - packageNames, - currentVersions, - (_currentPackage: string, completed: number, total: number) => { - this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`) - } - ) - : await getAllPackageData( - packageNames, - (_currentPackage: string, completed: number, total: number) => { - this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`) - } - ) + debugLog.info('PackageDetector', 'fetching version data via npm registry') + const allPackageData = await getAllPackageData( + packageNames, + (_currentPackage: string, completed: number, total: number) => { + this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`) + }, + currentVersions + ) debugLog.perf( 'PackageDetector', `registry fetch (${allPackageData.size}/${packageNames.length} resolved)`, diff --git a/src/interactive-ui.ts b/src/interactive-ui.ts index 5215625..e13ba6d 100644 --- a/src/interactive-ui.ts +++ b/src/interactive-ui.ts @@ -219,18 +219,20 @@ export class InteractiveUI { renderInterface() // Fetch metadata asynchronously - changelogFetcher.fetchPackageMetadata(currentState.name).then((metadata) => { - if (metadata) { - currentState.description = metadata.description - currentState.homepage = metadata.homepage - currentState.repository = metadata.releaseNotes - currentState.weeklyDownloads = metadata.weeklyDownloads - currentState.author = metadata.author as string | undefined - currentState.license = metadata.license - } - stateManager.setModalLoading(false) - renderInterface() - }) + changelogFetcher + .fetchPackageMetadata(currentState.name, currentState.latestVersion) + .then((metadata) => { + if (metadata) { + currentState.description = metadata.description + currentState.homepage = metadata.homepage + currentState.repository = metadata.releaseNotes + currentState.weeklyDownloads = metadata.weeklyDownloads + currentState.author = metadata.author as string | undefined + currentState.license = metadata.license + } + stateManager.setModalLoading(false) + renderInterface() + }) } else { // Closing modal stateManager.toggleInfoModal() diff --git a/src/services/changelog-fetcher.ts b/src/services/changelog-fetcher.ts index 7185153..e64b954 100644 --- a/src/services/changelog-fetcher.ts +++ b/src/services/changelog-fetcher.ts @@ -1,5 +1,5 @@ -import chalk from 'chalk' -import { JSDELIVR_CDN_URL } from '../config/constants' +import { NPM_REGISTRY_URL } from '../config/constants' +import { fetchExactPackageManifest } from './jsdelivr-registry' export interface PackageMetadata { description: string @@ -29,43 +29,77 @@ export interface PackageMetadata { export class ChangelogFetcher { private cache: Map = new Map() private failureCache: Set = new Set() // Track packages that failed to fetch + private inFlight: Map> = new Map() + + private getCacheKey(packageName: string, version?: string): string { + return `${packageName}@${version?.trim() || 'latest'}` + } /** * Fetch package metadata from npm registry * Uses a cached approach to avoid repeated requests */ - async fetchPackageMetadata(packageName: string): Promise { + async fetchPackageMetadata(packageName: string, version?: string): Promise { + const cacheKey = this.getCacheKey(packageName, version) + // Check if we already have this in cache - if (this.cache.has(packageName)) { - return this.cache.get(packageName)! + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey)! } // Check if we already failed to fetch this - if (this.failureCache.has(packageName)) { + if (this.failureCache.has(cacheKey)) { return null } + const inFlight = this.inFlight.get(cacheKey) + if (inFlight) { + return await inFlight + } + + const lookupPromise = this.fetchAndCachePackageMetadata(packageName, version).finally(() => { + this.inFlight.delete(cacheKey) + }) + this.inFlight.set(cacheKey, lookupPromise) + return await lookupPromise + } + + private async fetchAndCachePackageMetadata( + packageName: string, + version?: string + ): Promise { + const cacheKey = this.getCacheKey(packageName, version) + try { - // Fetch from npm registry - const response = await this.fetchFromRegistry(packageName) + const response = await this.fetchPackageManifest(packageName, version) if (!response) { - this.failureCache.add(packageName) + this.failureCache.add(cacheKey) return null } - const repositoryUrl = this.extractRepositoryUrl(response.repository?.url || '') + const repository = response.repository as { url?: string; type?: string } | undefined + const bugs = response.bugs as { url?: string } | undefined + const keywords = Array.isArray(response.keywords) ? (response.keywords as string[]) : [] + const author = + typeof response.author === 'object' && response.author !== null + ? ((response.author as { name?: string }).name ?? response.author) + : response.author + const repositoryUrl = this.extractRepositoryUrl(repository?.url || '') const npmUrl = `https://www.npmjs.com/package/${encodeURIComponent(packageName)}` const issuesUrl = repositoryUrl ? `${repositoryUrl}/issues` : undefined const metadata: PackageMetadata = { - description: response.description || 'No description available', - homepage: response.homepage, - repository: response.repository, - bugs: response.bugs, - keywords: response.keywords || [], - author: response.author?.name || response.author, - license: response.license, + description: + typeof response.description === 'string' && response.description + ? response.description + : 'No description available', + homepage: typeof response.homepage === 'string' ? response.homepage : undefined, + repository, + bugs, + keywords, + author: typeof author === 'string' ? author : undefined, + license: typeof response.license === 'string' ? response.license : undefined, repositoryUrl, npmUrl, issuesUrl, @@ -86,24 +120,34 @@ export class ChangelogFetcher { // Ignore download stats errors - optional data } - this.cache.set(packageName, metadata) + this.cache.set(cacheKey, metadata) return metadata - } catch (error) { + } catch { // Cache the failure to avoid retrying - this.failureCache.add(packageName) + this.failureCache.add(cacheKey) return null } } /** - * Fetch data from jsdelivr CDN - * Returns the package data by fetching package.json directly from jsdelivr + * Fetch metadata from a lightweight manifest endpoint. */ - private async fetchFromRegistry(packageName: string): Promise { + private async fetchPackageManifest( + packageName: string, + version?: string + ): Promise | null> { try { - // Fetch package.json directly from jsdelivr CDN (resolves to latest automatically) + const normalizedVersion = version?.trim() + if (normalizedVersion) { + const jsdelivrManifest = await fetchExactPackageManifest(packageName, normalizedVersion) + if (jsdelivrManifest) { + return jsdelivrManifest + } + } + + const npmPath = normalizedVersion ? normalizedVersion : 'latest' const response = await fetch( - `${JSDELIVR_CDN_URL}/${encodeURIComponent(packageName)}@latest/package.json`, + `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(npmPath)}`, { method: 'GET', headers: { @@ -116,17 +160,7 @@ export class ChangelogFetcher { return null } - const pkgData = (await response.json()) as Record - - return { - description: pkgData.description, - homepage: pkgData.homepage as string | undefined, - repository: pkgData.repository as any, - bugs: pkgData.bugs as any, - keywords: (pkgData.keywords || []) as string[], - author: pkgData.author as any, - license: pkgData.license as string | undefined, - } + return (await response.json()) as Record } catch { return null } @@ -188,7 +222,10 @@ export class ChangelogFetcher { * Get repository release URL for a package */ getRepositoryReleaseUrl(packageName: string, version: string): string | null { - const metadata = this.cache.get(packageName) + const metadata = + this.cache.get(this.getCacheKey(packageName, version)) ?? + this.cache.get(this.getCacheKey(packageName)) ?? + this.cache.get(packageName) if (!metadata || !metadata.releaseNotes) { return null } @@ -240,6 +277,7 @@ export class ChangelogFetcher { clearCache(): void { this.cache.clear() this.failureCache.clear() + this.inFlight.clear() } } diff --git a/src/services/index.ts b/src/services/index.ts index e2530a2..d50d376 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,5 +6,3 @@ export * from './npm-registry' export * from './jsdelivr-registry' export * from './changelog-fetcher' export * from './version-checker' -export * from './persistent-cache' -export * from './cache-manager' diff --git a/src/services/jsdelivr-registry.ts b/src/services/jsdelivr-registry.ts index b484632..675d5cf 100644 --- a/src/services/jsdelivr-registry.ts +++ b/src/services/jsdelivr-registry.ts @@ -1,5 +1,6 @@ import { Pool, request } from 'undici' import * as semver from 'semver' +import type { PackageVersionData } from './npm-registry' import { JSDELIVR_CDN_URL, MAX_CONCURRENT_REQUESTS, @@ -7,19 +8,12 @@ import { JSDELIVR_RETRY_TIMEOUTS, JSDELIVR_RETRY_DELAYS, } from '../config' -import { getAllPackageData } from './npm-registry' -import { packageCache, PackageVersionData } from './cache-manager' -import { ConsoleUtils } from '../ui/utils' -import { OnBatchReadyCallback } from '../types' import { debugLog } from '../utils' -// Batch configuration for progressive loading -const BATCH_SIZE = 5 -const BATCH_TIMEOUT_MS = 500 - const DEFAULT_JSDELIVR_RETRY_TIMEOUT_MS = 2000 const DEFAULT_JSDELIVR_POOL_TIMEOUT_MS = 60000 const MIN_JSDELIVR_CONNECT_TIMEOUT_MS = 500 +const EXACT_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ const toPositiveInteger = (value: number): number | null => { if (!Number.isFinite(value) || value <= 0) { @@ -257,17 +251,12 @@ const isExpectedTransientError = (error: unknown): boolean => isTimeoutError(error) || isTransientNetworkError(error) /** - * Fetches package.json from jsdelivr CDN for a specific version tag using undici pool. - * Uses connection pooling and keep-alive for maximum performance. - * Retries on transient failures while keeping a short fallback budget. - * @param packageName - The npm package name - * @param versionTag - The version tag (e.g., '14', 'latest') - * @returns The package.json content or null if not found + * Fetches a package.json manifest from jsDelivr for a version tag. */ -async function fetchPackageJsonFromJsdelivr( +async function fetchPackageManifestFromJsdelivr( packageName: string, versionTag: string -): Promise<{ version: string } | null> { +): Promise | null> { const url = `${JSDELIVR_CDN_URL}/${encodeURIComponent(packageName)}@${versionTag}/package.json` for (let attempt = 0; attempt < RETRY_TIMEOUTS.length; attempt++) { @@ -306,14 +295,13 @@ async function fetchPackageJsonFromJsdelivr( } const text = await body.text() - const data = JSON.parse(text) as { version?: unknown } - const version = typeof data.version === 'string' ? data.version.trim() : '' - debugLog.perf( - 'jsdelivr', - `fetch ${packageName}@${versionTag} → ${version || 'no version'}`, - tReq - ) - return version ? { version } : null + const data = JSON.parse(text) as unknown + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null + } + + debugLog.perf('jsdelivr', `fetch manifest ${packageName}@${versionTag}`, tReq) + return data as Record } catch (error) { if ( (isTimeoutError(error) || isTransientNetworkError(error)) && @@ -352,22 +340,35 @@ async function fetchPackageJsonFromJsdelivr( return null } -/** - * Fetches package version data from jsdelivr CDN for multiple packages. - * Uses undici connection pool for blazing fast performance with connection reuse. - * Falls back to npm registry immediately when jsdelivr fails (interleaved, not sequential). - * Supports batched callbacks for progressive UI updates. - * @param packageNames - Array of package names to fetch - * @param currentVersions - Optional map of package names to their current versions - * @param onProgress - Optional progress callback - * @param onBatchReady - Optional callback for batch updates (fires every BATCH_SIZE packages or BATCH_TIMEOUT_MS) - * @returns Map of package names to their version data - */ +const inFlightManifests = new Map | null>>() + +export async function fetchExactPackageManifest( + packageName: string, + version: string +): Promise | null> { + const normalizedVersion = version.trim() + if (!EXACT_VERSION_PATTERN.test(normalizedVersion) || !semver.valid(normalizedVersion)) { + debugLog.warn('jsdelivr', `skipping non-exact version lookup for ${packageName}@${version}`) + return null + } + + const cacheKey = `${packageName}@${normalizedVersion}` + const inFlight = inFlightManifests.get(cacheKey) + if (inFlight) { + return await inFlight + } + + const lookupPromise = fetchPackageManifestFromJsdelivr(packageName, normalizedVersion).finally(() => { + inFlightManifests.delete(cacheKey) + }) + inFlightManifests.set(cacheKey, lookupPromise) + return await lookupPromise +} + export async function getAllPackageDataFromJsdelivr( packageNames: string[], currentVersions?: Map, - onProgress?: (currentPackage: string, completed: number, total: number) => void, - onBatchReady?: OnBatchReadyCallback + onProgress?: (currentPackage: string, completed: number, total: number) => void ): Promise> { const packageData = new Map() @@ -377,138 +378,41 @@ export async function getAllPackageDataFromJsdelivr( const total = packageNames.length let completedCount = 0 - let progressCallback = onProgress - let batchReadyCallback = onBatchReady - - // Batch buffer for progressive updates - let batchBuffer: Array<{ name: string; data: PackageVersionData }> = [] - let batchTimer: NodeJS.Timeout | null = null - - const emitProgress = (packageName: string, completed: number, packageTotal: number) => { - if (!progressCallback) { - return - } - - try { - progressCallback(packageName, completed, packageTotal) - } catch (error) { - console.error('Progress callback failed, disabling progress updates for this run.', error) - progressCallback = undefined - } - } - - const emitBatch = (batch: Array<{ name: string; data: PackageVersionData }>) => { - if (!batchReadyCallback) { - return - } - - try { - batchReadyCallback(batch) - } catch (error) { - console.error('Batch callback failed, disabling batch updates for this run.', error) - batchReadyCallback = undefined - } - } - - // Helper to flush the current batch - const flushBatch = () => { - if (batchBuffer.length > 0) { - const batch = [...batchBuffer] - batchBuffer = [] - emitBatch(batch) - } - if (batchTimer) { - clearTimeout(batchTimer) - batchTimer = null - } - } - - // Helper to add package to batch and flush if needed - const addToBatch = (packageName: string, data: PackageVersionData) => { - if (!batchReadyCallback) { - return - } - - batchBuffer.push({ name: packageName, data }) - - // Flush if batch is full - if (batchBuffer.length >= BATCH_SIZE) { - flushBatch() - } else if (!batchTimer) { - // Set timer to flush batch after timeout - batchTimer = setTimeout(flushBatch, BATCH_TIMEOUT_MS) - } - } - - // Process individual package fetch with immediate npm fallback on failure const inFlightLookups = new Map>() - const fetchFromNpmFallback = async (packageName: string): Promise => { - const tFallback = Date.now() - debugLog.info('jsdelivr', `falling back to npm registry for ${packageName}`) - try { - const npmData = await getAllPackageData([packageName]) - const result = npmData.get(packageName) ?? null - - if (result) { - packageCache.set(packageName, result) - debugLog.perf( - 'jsdelivr', - `npm fallback resolved ${packageName} → ${result.latestVersion}`, - tFallback - ) - } else { - debugLog.warn('jsdelivr', `npm fallback returned no data for ${packageName}`) - } - - return result - } catch (error) { - debugLog.error('jsdelivr', `npm fallback failed for ${packageName}`, error) - return null - } - } - - const fetchFreshPackageData = async ( + const fetchPackageData = async ( packageName: string, currentVersion: string | undefined ): Promise => { - try { - const majorVersion = extractMajorVersion(currentVersion) - - const latestResult = await fetchPackageJsonFromJsdelivr(packageName, 'latest') - if (!latestResult) { - return await fetchFromNpmFallback(packageName) - } - - const latestVersion = latestResult.version - const latestMajorVersion = extractMajorVersion(latestVersion) - const shouldFetchMajorVersion = Boolean( - majorVersion && (latestMajorVersion === null || majorVersion !== latestMajorVersion) - ) - const majorResult = shouldFetchMajorVersion - ? await fetchPackageJsonFromJsdelivr(packageName, majorVersion as string) - : null - const allVersions = [latestVersion] - - if (majorResult && majorResult.version !== latestVersion) { - allVersions.push(majorResult.version) - } - - const sortedVersions = sortVersionsDescending(allVersions) - const orderedVersions = - sortedVersions[0] === latestVersion - ? sortedVersions - : [latestVersion, ...sortedVersions.filter((version) => version !== latestVersion)] - - const result: PackageVersionData = { - latestVersion, - allVersions: orderedVersions, - } + const latestManifest = await fetchPackageManifestFromJsdelivr(packageName, 'latest') + const latestVersion = + typeof latestManifest?.version === 'string' ? latestManifest.version.trim() : '' + if (!latestVersion) { + return null + } - packageCache.set(packageName, result) - return result - } catch { - return await fetchFromNpmFallback(packageName) + const majorVersion = extractMajorVersion(currentVersion) + const latestMajorVersion = extractMajorVersion(latestVersion) + const shouldFetchMajorVersion = Boolean( + majorVersion && (latestMajorVersion === null || latestMajorVersion !== majorVersion) + ) + const majorManifest = shouldFetchMajorVersion + ? await fetchPackageManifestFromJsdelivr(packageName, majorVersion as string) + : null + const majorResolvedVersion = + typeof majorManifest?.version === 'string' ? majorManifest.version.trim() : '' + + const sortedVersions = sortVersionsDescending( + [latestVersion, majorResolvedVersion].filter(Boolean) + ) + const allVersions = + sortedVersions[0] === latestVersion + ? sortedVersions + : [latestVersion, ...sortedVersions.filter((version) => version !== latestVersion)] + + return { + latestVersion, + allVersions, } } @@ -516,70 +420,35 @@ export async function getAllPackageDataFromJsdelivr( packageName: string, currentVersion: string | undefined ): Promise => { - const cached = packageCache.get(packageName) - if (cached) { - debugLog.info('jsdelivr', `cache hit: ${packageName} → ${cached.latestVersion}`) - return cached - } - const inFlight = inFlightLookups.get(packageName) if (inFlight) { return await inFlight } - const lookupPromise = fetchFreshPackageData(packageName, currentVersion).finally(() => { + const lookupPromise = fetchPackageData(packageName, currentVersion).finally(() => { inFlightLookups.delete(packageName) }) inFlightLookups.set(packageName, lookupPromise) return await lookupPromise } - const fetchPackageWithFallback = async (packageName: string): Promise => { - try { - const currentVersion = currentVersions?.get(packageName) - const result = await getPackageData(packageName, currentVersion) - - if (result) { - packageData.set(packageName, result) - addToBatch(packageName, result) + await Promise.all( + packageNames.map(async (packageName) => { + try { + const result = await getPackageData(packageName, currentVersions?.get(packageName)) + if (result) { + packageData.set(packageName, result) + } + } finally { + completedCount++ + onProgress?.(packageName, completedCount, total) } - } catch (error) { - console.error( - `Failed to resolve package data for ${packageName}; continuing with others.`, - error - ) - } finally { - completedCount++ - emitProgress(packageName, completedCount, total) - } - } - - try { - // Fire all requests simultaneously - each request internally handles retries/fallback. - await Promise.all(packageNames.map(fetchPackageWithFallback)) - } finally { - // Flush any remaining batch items - flushBatch() - - // Flush persistent cache to disk - packageCache.flush() - - // Clear the progress line if no custom progress handler - if (!onProgress) { - ConsoleUtils.clearProgress() - } - } + }) + ) return packageData } -/** - * Clear the package cache (useful for testing) - */ -export function clearJsdelivrPackageCache(): void { - packageCache.clear() -} - /** * Close the jsDelivr connection pool (useful for graceful shutdown) */ diff --git a/src/services/npm-registry.ts b/src/services/npm-registry.ts index f12d2aa..1f0fceb 100644 --- a/src/services/npm-registry.ts +++ b/src/services/npm-registry.ts @@ -1,19 +1,71 @@ import * as semver from 'semver' import { NPM_REGISTRY_URL, REQUEST_TIMEOUT } from '../config' -import { packageCache, PackageVersionData } from './cache-manager' +import { getAllPackageDataFromJsdelivr } from './jsdelivr-registry' import { ConsoleUtils } from '../ui/utils' +export interface PackageVersionData { + latestVersion: string + allVersions: string[] +} + +const inFlightLookups = new Map>() + +const isRetryableStatus = (statusCode: number): boolean => + statusCode === 408 || statusCode === 429 || statusCode >= 500 + +const isTransientNetworkError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false + } + + const maybeCode = (error as Error & { code?: string }).code + return ( + error.name === 'AbortError' || + maybeCode === 'ENOTFOUND' || + maybeCode === 'EAI_AGAIN' || + maybeCode === 'ECONNRESET' || + maybeCode === 'ECONNREFUSED' || + maybeCode === 'ETIMEDOUT' || + maybeCode === 'EPIPE' + ) +} + +const fetchFromJsdelivrFallback = async ( + packageName: string, + currentVersion: string | undefined +): Promise => { + const jsdelivrData = await getAllPackageDataFromJsdelivr( + [packageName], + currentVersion ? new Map([[packageName, currentVersion]]) : undefined + ) + return jsdelivrData.get(packageName) ?? { latestVersion: 'unknown', allVersions: [] } +} + +async function getFreshPackageData( + packageName: string, + currentVersion: string | undefined +): Promise { + const cacheKey = `${packageName}@${currentVersion ?? ''}` + const inFlight = inFlightLookups.get(cacheKey) + if (inFlight) { + return await inFlight + } + + const lookupPromise = fetchPackageFromRegistryWithFallback(packageName, currentVersion).finally(() => { + inFlightLookups.delete(cacheKey) + }) + inFlightLookups.set(cacheKey, lookupPromise) + return await lookupPromise +} + /** * Fetches package data from npm registry. - * Uses the shared CacheManager for caching. + * Falls back to jsDelivr when npm is temporarily unavailable. */ -async function fetchPackageFromRegistry(packageName: string): Promise { - // Use CacheManager for unified caching (memory + disk) - const cached = packageCache.get(packageName) - if (cached) { - return cached - } - +async function fetchPackageFromRegistryWithFallback( + packageName: string, + currentVersion: string | undefined +): Promise { try { const url = `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}` @@ -32,46 +84,35 @@ async function fetchPackageFromRegistry(packageName: string): Promise - description?: string - homepage?: string - repository?: any - bugs?: any - keywords?: string[] - author?: any - license?: string - 'dist-tags'?: Record } - // Extract versions and filter to valid semver (X.Y.Z format, no pre-releases) - const allVersions = Object.keys(data.versions || {}).filter((version) => { - // Match only X.Y.Z format (no pre-release, no build metadata) - return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(version) - }) + const allVersions = Object.keys(data.versions || {}).filter((version) => + /^[0-9]+\.[0-9]+\.[0-9]+$/.test(version) + ) - // Sort versions to find the latest const sortedVersions = allVersions.sort(semver.rcompare) const latestVersion = sortedVersions.length > 0 ? sortedVersions[0] : 'unknown' - const result: PackageVersionData = { + return { latestVersion, allVersions, } - - // Cache the result using CacheManager (handles both memory and disk) - packageCache.set(packageName, result) - - return result } finally { clearTimeout(timeoutId) } } catch (error) { - // Return fallback data for failed packages + if (isTransientNetworkError(error)) { + return await fetchFromJsdelivrFallback(packageName, currentVersion) + } return { latestVersion: 'unknown', allVersions: [] } } } @@ -83,7 +124,8 @@ async function fetchPackageFromRegistry(packageName: string): Promise void + onProgress?: (currentPackage: string, completed: number, total: number) => void, + currentVersions?: Map ): Promise> { const packageData = new Map() @@ -94,10 +136,8 @@ export async function getAllPackageData( const total = packageNames.length let completedCount = 0 - // Fire all requests simultaneously - // Concurrency is handled naturally by the event loop with fetch const allPromises = packageNames.map(async (packageName) => { - const data = await fetchPackageFromRegistry(packageName) + const data = await getFreshPackageData(packageName, currentVersions?.get(packageName)) packageData.set(packageName, data) completedCount++ @@ -110,9 +150,6 @@ export async function getAllPackageData( // Wait for all requests to complete await Promise.all(allPromises) - // Flush persistent cache to disk - packageCache.flush() - // Clear the progress line if no custom progress handler if (!onProgress) { ConsoleUtils.clearProgress() @@ -122,8 +159,8 @@ export async function getAllPackageData( } /** - * Clear the package cache (useful for testing) + * Retained for backward compatibility. Registry responses are fresh-by-default. */ export function clearPackageCache(): void { - packageCache.clear() + inFlightLookups.clear() } diff --git a/test/integration/services.test.ts b/test/integration/services.test.ts index fa96519..b078303 100644 --- a/test/integration/services.test.ts +++ b/test/integration/services.test.ts @@ -1,10 +1,16 @@ import { describe, it, expect, beforeEach } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' import { ChangelogFetcher } from '../../src/services/changelog-fetcher' import { getAllPackageData } from '../../src/services/npm-registry' -import { getAllPackageDataFromJsdelivr } from '../../src/services/jsdelivr-registry' +import { fetchExactPackageManifest } from '../../src/services/jsdelivr-registry' import { PACKAGE_NAME } from '../../src/config/constants' describe('Services Integration Tests', () => { + const packageVersion = JSON.parse( + readFileSync(join(process.cwd(), 'package.json'), 'utf-8') + ).version as string + describe(`ChangelogFetcher with ${PACKAGE_NAME}`, () => { let fetcher: ChangelogFetcher @@ -14,7 +20,7 @@ describe('Services Integration Tests', () => { }) it(`should fetch metadata for ${PACKAGE_NAME}`, async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) + const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME, packageVersion) expect(metadata).not.toBeNull() expect(metadata?.description).toBeTruthy() @@ -32,11 +38,11 @@ describe('Services Integration Tests', () => { it('should use cache on second fetch', async () => { const start1 = Date.now() - await fetcher.fetchPackageMetadata('inup') + await fetcher.fetchPackageMetadata('inup', packageVersion) const duration1 = Date.now() - start1 const start2 = Date.now() - await fetcher.fetchPackageMetadata('inup') + await fetcher.fetchPackageMetadata('inup', packageVersion) const duration2 = Date.now() - start2 // Second fetch should be significantly faster (cached) @@ -85,66 +91,19 @@ describe('Services Integration Tests', () => { }, 10000) }) - describe(`jsdelivr-registry with ${PACKAGE_NAME}`, () => { - it(`should fetch version data for ${PACKAGE_NAME} from jsdelivr`, async () => { - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - - expect(result.size).toBe(1) - const testData = result.get(PACKAGE_NAME) - expect(testData).toBeDefined() - expect(testData?.latestVersion).toMatch(/^\d+\.\d+\.\d+$/) - expect(testData?.allVersions.length).toBeGreaterThan(0) - }, 10000) - - it('should fetch both latest and major version', async () => { - const currentVersions = new Map([[PACKAGE_NAME, '1.0.0']]) - - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME], currentVersions) - - const testData = result.get(PACKAGE_NAME) - expect(testData).toBeDefined() - expect(testData?.allVersions.length).toBeGreaterThanOrEqual(1) + describe(`jsdelivr exact manifest with ${PACKAGE_NAME}`, () => { + it(`should fetch exact package manifest for ${PACKAGE_NAME}`, async () => { + const manifest = await fetchExactPackageManifest(PACKAGE_NAME, packageVersion) - // Should have at least the latest version - expect(testData?.latestVersion).toBeTruthy() + expect(manifest).not.toBeNull() + expect(manifest?.name).toBe(PACKAGE_NAME) + expect(manifest?.version).toBe(packageVersion) }, 10000) - it('should track progress with callback', async () => { - const progressUpdates: Array<{ package: string; completed: number; total: number }> = [] - - await getAllPackageDataFromJsdelivr( - [PACKAGE_NAME, PACKAGE_NAME, PACKAGE_NAME], - undefined, - (pkg, completed, total) => { - progressUpdates.push({ package: pkg, completed, total }) - } - ) + it('should reject non-exact version lookups', async () => { + const manifest = await fetchExactPackageManifest(PACKAGE_NAME, 'latest') - expect(progressUpdates.length).toBe(3) - expect(progressUpdates[0].total).toBe(3) - expect(progressUpdates[2].completed).toBe(3) - }, 15000) - }) - - describe('Performance comparison: npm vs jsdelivr', () => { - it(`should compare fetch performance for ${PACKAGE_NAME}`, async () => { - // Test npm registry - const npmStart = Date.now() - await getAllPackageData([PACKAGE_NAME]) - const npmDuration = Date.now() - npmStart - - // Test jsdelivr - const jsdelivrStart = Date.now() - await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - const jsdelivrDuration = Date.now() - jsdelivrStart - - // Both should complete in reasonable time - expect(npmDuration).toBeLessThan(10000) - expect(jsdelivrDuration).toBeLessThan(10000) - - // Log the comparison (for informational purposes) - console.log(`npm registry: ${npmDuration}ms`) - console.log(`jsdelivr: ${jsdelivrDuration}ms`) - }, 20000) + expect(manifest).toBeNull() + }) }) }) diff --git a/test/unit/services/changelog-fetcher.test.ts b/test/unit/services/changelog-fetcher.test.ts index ffd1e6b..e4e0d0d 100644 --- a/test/unit/services/changelog-fetcher.test.ts +++ b/test/unit/services/changelog-fetcher.test.ts @@ -1,108 +1,205 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +const { fetchExactPackageManifestMock } = vi.hoisted(() => ({ + fetchExactPackageManifestMock: vi.fn(), +})) + +vi.mock('../../../src/services/jsdelivr-registry', () => ({ + fetchExactPackageManifest: fetchExactPackageManifestMock, +})) + import { ChangelogFetcher } from '../../../src/services/changelog-fetcher' -import { PACKAGE_NAME } from '../../../src/config/constants' describe('ChangelogFetcher', () => { let fetcher: ChangelogFetcher + const fetchMock = vi.fn() beforeEach(() => { fetcher = new ChangelogFetcher() fetcher.clearCache() + fetchMock.mockReset() + fetchExactPackageManifestMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() }) describe('fetchPackageMetadata()', () => { - it(`should fetch metadata for ${PACKAGE_NAME}`, async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) - - expect(metadata).not.toBeNull() - expect(metadata?.description).toBeTruthy() - expect(metadata?.repositoryUrl).toBeTruthy() - expect(metadata?.repositoryUrl).toContain('github.com') - expect(metadata?.npmUrl).toBe(`https://www.npmjs.com/package/${PACKAGE_NAME}`) - expect(metadata?.license).toBeTruthy() - }, 10000) - - it('should return null for nonexistent package', async () => { - const metadata = await fetcher.fetchPackageMetadata('this-package-definitely-does-not-exist-123456789') - - expect(metadata).toBeNull() - }, 10000) - - it('should use cache on second fetch', async () => { - const start1 = Date.now() - await fetcher.fetchPackageMetadata(PACKAGE_NAME) - const duration1 = Date.now() - start1 - - const start2 = Date.now() - await fetcher.fetchPackageMetadata(PACKAGE_NAME) - const duration2 = Date.now() - start2 - - // Second fetch should be significantly faster (cached) - expect(duration2).toBeLessThan(duration1 / 2) - }, 10000) - - it('should cache failures to avoid retrying', async () => { - const start1 = Date.now() - await fetcher.fetchPackageMetadata('nonexistent-package-xyz-123') - const duration1 = Date.now() - start1 - - const start2 = Date.now() - await fetcher.fetchPackageMetadata('nonexistent-package-xyz-123') - const duration2 = Date.now() - start2 - - // Second fetch should be instant (cached failure) - expect(duration2).toBeLessThan(10) - }, 10000) - - it('should extract repository URL correctly', async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) - - expect(metadata).not.toBeNull() - expect(metadata?.repositoryUrl).toBeTruthy() - // Should not have .git suffix - expect(metadata?.repositoryUrl).not.toContain('.git') - // Should not have git+ prefix - expect(metadata?.repositoryUrl).toMatch(/^https?:\/\//) - }, 10000) - - it('should generate release notes URL', async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) - - expect(metadata).not.toBeNull() - if (metadata?.repositoryUrl) { - expect(metadata.releaseNotes).toBe(`${metadata.repositoryUrl}/releases`) - } - }, 10000) + it('prefers an exact jsdelivr manifest when a pinned version is provided', async () => { + fetchExactPackageManifestMock.mockResolvedValue({ + description: 'Demo package', + homepage: 'https://example.com', + repository: { url: 'git+https://github.com/demo/repo.git' }, + bugs: { url: 'https://github.com/demo/repo/issues' }, + keywords: ['demo'], + author: { name: 'Demo Author' }, + license: 'MIT', + }) + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 1234 }), + }) + + const metadata = await fetcher.fetchPackageMetadata('demo-pkg', '1.2.3') + + expect(fetchExactPackageManifestMock).toHaveBeenCalledWith('demo-pkg', '1.2.3') + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(metadata?.repositoryUrl).toBe('https://github.com/demo/repo') + expect(metadata?.weeklyDownloads).toBe(1234) + }) - it('should generate issues URL', async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) + it('fetches metadata from npm registry and download stats once', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: 'Demo package', + homepage: 'https://example.com', + repository: { url: 'git+https://github.com/demo/repo.git' }, + bugs: { url: 'https://github.com/demo/repo/issues' }, + keywords: ['demo'], + author: { name: 'Demo Author' }, + license: 'MIT', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 1234 }), + }) + + const metadata = await fetcher.fetchPackageMetadata('demo-pkg') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(metadata).toEqual({ + description: 'Demo package', + homepage: 'https://example.com', + repository: { url: 'git+https://github.com/demo/repo.git' }, + bugs: { url: 'https://github.com/demo/repo/issues' }, + keywords: ['demo'], + author: 'Demo Author', + license: 'MIT', + repositoryUrl: 'https://github.com/demo/repo', + npmUrl: 'https://www.npmjs.com/package/demo-pkg', + issuesUrl: 'https://github.com/demo/repo/issues', + releaseNotes: 'https://github.com/demo/repo/releases', + weeklyDownloads: 1234, + }) + }) - expect(metadata).not.toBeNull() - if (metadata?.repositoryUrl) { - expect(metadata.issuesUrl).toBe(`${metadata.repositoryUrl}/issues`) - } - }, 10000) + it('returns null for nonexistent package and memoizes the failure', async () => { + fetchMock.mockResolvedValue({ + ok: false, + json: async () => ({}), + }) - it('should fetch package metadata with optional weekly downloads', async () => { - const metadata = await fetcher.fetchPackageMetadata(PACKAGE_NAME) + const first = await fetcher.fetchPackageMetadata('missing-pkg') + const second = await fetcher.fetchPackageMetadata('missing-pkg') - expect(metadata).not.toBeNull() - // Weekly downloads is optional - may not always be available - if (metadata?.weeklyDownloads !== undefined) { - expect(metadata.weeklyDownloads).toBeGreaterThanOrEqual(0) - } - }, 10000) + expect(first).toBeNull() + expect(second).toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('reuses cached metadata on repeated calls', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: 'Demo package', + repository: { url: 'https://github.com/demo/repo.git' }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 42 }), + }) + + const first = await fetcher.fetchPackageMetadata('demo-pkg') + const second = await fetcher.fetchPackageMetadata('demo-pkg') + + expect(first).toEqual(second) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('dedupes concurrent requests while metadata is in flight', async () => { + let resolveRegistry: + | ((value: { + ok: boolean + json: () => Promise> + }) => void) + | undefined + let resolveDownloads: + | ((value: { + ok: boolean + json: () => Promise> + }) => void) + | undefined + + fetchMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRegistry = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDownloads = resolve + }) + ) + + const first = fetcher.fetchPackageMetadata('demo-pkg') + const second = fetcher.fetchPackageMetadata('demo-pkg') + + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveRegistry?.({ + ok: true, + json: async () => ({ + description: 'Demo package', + repository: { url: 'https://github.com/demo/repo.git' }, + }), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchMock).toHaveBeenCalledTimes(2) + + resolveDownloads?.({ + ok: true, + json: async () => ({ downloads: 7 }), + }) + + const [firstResult, secondResult] = await Promise.all([first, second]) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(firstResult).toEqual(secondResult) + }) }) describe('getRepositoryReleaseUrl()', () => { it('should return release URL for cached package', async () => { - await fetcher.fetchPackageMetadata(PACKAGE_NAME) - - const releaseUrl = fetcher.getRepositoryReleaseUrl(PACKAGE_NAME, '1.0.0') + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: 'Demo package', + repository: { url: 'https://github.com/demo/repo.git' }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 7 }), + }) + + await fetcher.fetchPackageMetadata('demo-pkg') + + const releaseUrl = fetcher.getRepositoryReleaseUrl('demo-pkg', '1.0.0') expect(releaseUrl).toBeTruthy() expect(releaseUrl).toContain('/releases/tag/v1.0.0') - }, 10000) + }) it('should return null for uncached package', () => { const releaseUrl = fetcher.getRepositoryReleaseUrl('unknown-package', '1.0.0') @@ -145,21 +242,40 @@ describe('ChangelogFetcher', () => { describe('clearCache()', () => { it('should clear both success and failure caches', async () => { - // Fetch test package - await fetcher.fetchPackageMetadata(PACKAGE_NAME) - - // Fetch nonexistent package (failure) - await fetcher.fetchPackageMetadata('nonexistent-pkg-xyz') - - // Clear cache + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: 'Demo package', + repository: { url: 'https://github.com/demo/repo.git' }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 7 }), + }) + .mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: 'Demo package', + repository: { url: 'https://github.com/demo/repo.git' }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ downloads: 7 }), + }) + + await fetcher.fetchPackageMetadata('demo-pkg') + await fetcher.fetchPackageMetadata('missing-pkg') fetcher.clearCache() + await fetcher.fetchPackageMetadata('demo-pkg') - // Both should be refetched - const start = Date.now() - await fetcher.fetchPackageMetadata(PACKAGE_NAME) - const duration = Date.now() - start - - expect(duration).toBeGreaterThan(10) - }, 10000) + expect(fetchMock).toHaveBeenCalledTimes(5) + }) }) }) diff --git a/test/unit/services/jsdelivr-registry.retries.test.ts b/test/unit/services/jsdelivr-registry.retries.test.ts index 850f3e0..bddeeb2 100644 --- a/test/unit/services/jsdelivr-registry.retries.test.ts +++ b/test/unit/services/jsdelivr-registry.retries.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const requestMock = vi.fn() const closeMock = vi.fn() -const getAllPackageDataMock = vi.fn() const PoolMock = vi.fn( class MockPool { close = closeMock @@ -14,10 +13,6 @@ vi.mock('undici', () => ({ request: requestMock, })) -vi.mock('../../../src/services/npm-registry', () => ({ - getAllPackageData: getAllPackageDataMock, -})) - vi.mock('../../../src/config', async () => { const actual = await vi.importActual('../../../src/config') return { @@ -27,9 +22,7 @@ vi.mock('../../../src/config', async () => { } }) -const { getAllPackageDataFromJsdelivr, clearJsdelivrPackageCache } = - await import('../../../src/services/jsdelivr-registry') -const { persistentCache } = await import('../../../src/services/persistent-cache') +const { fetchExactPackageManifest } = await import('../../../src/services/jsdelivr-registry') const { JSDELIVR_RETRY_TIMEOUTS } = await import('../../../src/config') const createTimeoutError = () => { @@ -41,144 +34,62 @@ const createTimeoutError = () => { describe('jsdelivr-registry retries', () => { beforeEach(() => { vi.useRealTimers() - vi.clearAllMocks() - clearJsdelivrPackageCache() - persistentCache.clearCache() + requestMock.mockReset() + closeMock.mockReset() + PoolMock.mockClear() }) - it('retries jsDelivr request and succeeds before fallback', async () => { + it('retries jsDelivr exact-manifest request and succeeds', async () => { requestMock.mockRejectedValueOnce(createTimeoutError()).mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(2) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) }) - it('falls back to npm after jsDelivr retry budget is exhausted without noisy logs', async () => { + it('returns null after retry budget is exhausted without noisy logs', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) requestMock.mockRejectedValue(createTimeoutError()) - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(JSDELIVR_RETRY_TIMEOUTS.length) - expect(getAllPackageDataMock).toHaveBeenCalledWith(['demo-pkg']) expect(consoleErrorSpy).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }) + expect(result).toBeNull() consoleErrorSpy.mockRestore() }) - it('reports progress exactly once per package when retries are exhausted', async () => { - requestMock.mockRejectedValue(createTimeoutError()) - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - const progressUpdates: Array<{ pkg: string; completed: number; total: number }> = [] - - await getAllPackageDataFromJsdelivr(['demo-pkg'], undefined, (pkg, completed, total) => { - progressUpdates.push({ pkg, completed, total }) - }) - - expect(progressUpdates).toEqual([{ pkg: 'demo-pkg', completed: 1, total: 1 }]) - }) - - it('coalesces duplicate in-flight jsDelivr lookups for the same package', async () => { + it('coalesces duplicate in-flight exact-manifest lookups for the same package/version', async () => { requestMock.mockResolvedValue({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const progressUpdates: Array<{ pkg: string; completed: number; total: number }> = [] - - const result = await getAllPackageDataFromJsdelivr( - ['demo-pkg', 'demo-pkg'], - undefined, - (pkg, completed, total) => { - progressUpdates.push({ pkg, completed, total }) - } - ) - expect(requestMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) - expect(progressUpdates).toEqual([ - { pkg: 'demo-pkg', completed: 1, total: 2 }, - { pkg: 'demo-pkg', completed: 2, total: 2 }, + const [first, second] = await Promise.all([ + fetchExactPackageManifest('demo-pkg', '1.2.3'), + fetchExactPackageManifest('demo-pkg', '1.2.3'), ]) - }) - - it('coalesces duplicate npm fallbacks when jsDelivr retries are exhausted', async () => { - requestMock.mockRejectedValue(createTimeoutError()) - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - const progressUpdates: Array<{ pkg: string; completed: number; total: number }> = [] - - const result = await getAllPackageDataFromJsdelivr( - ['demo-pkg', 'demo-pkg'], - undefined, - (pkg, completed, total) => { - progressUpdates.push({ pkg, completed, total }) - } - ) - expect(requestMock).toHaveBeenCalledTimes(JSDELIVR_RETRY_TIMEOUTS.length) - expect(getAllPackageDataMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).toHaveBeenCalledWith(['demo-pkg']) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '9.9.9', - allVersions: ['9.9.9'], + expect(requestMock).toHaveBeenCalledTimes(1) + expect(first).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) - expect(progressUpdates).toEqual([ - { pkg: 'demo-pkg', completed: 1, total: 2 }, - { pkg: 'demo-pkg', completed: 2, total: 2 }, - ]) + expect(second).toEqual(first) }) - it('retries on transient HTTP status and succeeds without npm fallback', async () => { + it('retries on transient HTTP status and succeeds', async () => { requestMock .mockResolvedValueOnce({ statusCode: 503, @@ -189,17 +100,16 @@ describe('jsdelivr-registry retries', () => { .mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(2) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) }) @@ -218,11 +128,11 @@ describe('jsdelivr-registry retries', () => { .mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const pending = getAllPackageDataFromJsdelivr(['demo-pkg']) + const pending = fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(19) @@ -232,10 +142,9 @@ describe('jsdelivr-registry retries', () => { const result = await pending expect(requestMock).toHaveBeenCalledTimes(2) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) }) @@ -254,20 +163,20 @@ describe('jsdelivr-registry retries', () => { .mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const pending = getAllPackageDataFromJsdelivr(['demo-pkg']) + const pending = fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) const result = await pending expect(requestMock).toHaveBeenCalledTimes(2) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) }) @@ -286,11 +195,11 @@ describe('jsdelivr-registry retries', () => { .mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const pending = getAllPackageDataFromJsdelivr(['demo-pkg']) + const pending = fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(19) @@ -300,13 +209,13 @@ describe('jsdelivr-registry retries', () => { const result = await pending expect(requestMock).toHaveBeenCalledTimes(2) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) }) - it('logs unexpected parse errors once and then falls back to npm', async () => { + it('logs unexpected parse errors once and returns null', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) requestMock.mockResolvedValue({ statusCode: 200, @@ -314,45 +223,18 @@ describe('jsdelivr-registry retries', () => { text: async () => '{invalid-json', }, }) - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).toHaveBeenCalledWith(['demo-pkg']) expect(consoleErrorSpy).toHaveBeenCalledTimes(1) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }) + expect(result).toBeNull() consoleErrorSpy.mockRestore() }) - it('falls back immediately when latest fails and skips major fetch', async () => { - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - + it('returns null on non-retryable http status', async () => { requestMock.mockImplementation((url: string) => { - if (url.includes('@latest')) { + if (url.includes('@1.2.3')) { return Promise.resolve({ statusCode: 404, body: { @@ -365,121 +247,33 @@ describe('jsdelivr-registry retries', () => { }) const result = await Promise.race([ - getAllPackageDataFromJsdelivr(['demo-pkg'], new Map([['demo-pkg', '1.0.0']])), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout waiting for fallback')), 250) - ), + fetchExactPackageManifest('demo-pkg', '1.2.3'), + new Promise((resolve) => setTimeout(() => resolve(null), 250)), ]) - expect(getAllPackageDataMock).toHaveBeenCalledWith(['demo-pkg']) - expect(requestMock).toHaveBeenCalledTimes(1) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }) - }) - - it('skips major request when current major matches latest major', async () => { - requestMock.mockResolvedValue({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: '1.2.3' }), - }, - }) - - const result = await getAllPackageDataFromJsdelivr( - ['demo-pkg'], - new Map([['demo-pkg', '1.0.0']]) - ) - - expect(requestMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) - }) - - it('falls back when jsDelivr response contains a non-string version', async () => { - requestMock.mockResolvedValue({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: 123 }), - }, - }) - getAllPackageDataMock.mockResolvedValue( - new Map([ - [ - 'demo-pkg', - { - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }, - ], - ]) - ) - - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) - expect(requestMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).toHaveBeenCalledWith(['demo-pkg']) - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '9.9.9', - allVersions: ['9.9.9'], - }) + expect(result).toBeNull() }) - it('skips major request when current version is not a valid semver', async () => { + it('returns null when jsDelivr response is not an object', async () => { requestMock.mockResolvedValue({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify('not-an-object'), }, }) - const result = await getAllPackageDataFromJsdelivr( - ['demo-pkg'], - new Map([['demo-pkg', 'not-a-version']]) - ) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(1) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) + expect(result).toBeNull() }) - it('keeps latest version first when it is not semver and major version is semver', async () => { - requestMock.mockImplementation((url: string) => { - if (url.includes('@latest')) { - return Promise.resolve({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: 'stable' }), - }, - }) - } - - return Promise.resolve({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: '1.0.0' }), - }, - }) - }) - - const result = await getAllPackageDataFromJsdelivr( - ['demo-pkg'], - new Map([['demo-pkg', '1.2.0']]) - ) + it('returns null for non-exact versions before issuing a request', async () => { + const result = await fetchExactPackageManifest('demo-pkg', 'latest') - expect(requestMock).toHaveBeenCalledTimes(2) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: 'stable', - allVersions: ['stable', '1.0.0'], - }) + expect(requestMock).not.toHaveBeenCalled() + expect(result).toBeNull() }) it('retries on transient network errors and succeeds', async () => { @@ -491,72 +285,16 @@ describe('jsdelivr-registry retries', () => { requestMock.mockRejectedValueOnce(dnsError).mockResolvedValueOnce({ statusCode: 200, body: { - text: async () => JSON.stringify({ version: '1.2.3' }), - }, - }) - - const result = await getAllPackageDataFromJsdelivr(['demo-pkg']) - - expect(requestMock).toHaveBeenCalledTimes(2) - expect(getAllPackageDataMock).not.toHaveBeenCalled() - expect(result.get('demo-pkg')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) - }) - - it('continues fetching when progress callback throws', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - requestMock.mockResolvedValue({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: '1.2.3' }), + text: async () => JSON.stringify({ name: 'demo-pkg', version: '1.2.3' }), }, }) - const result = await getAllPackageDataFromJsdelivr( - ['demo-a', 'demo-b'], - undefined, - () => { - throw new Error('progress callback failed') - } - ) + const result = await fetchExactPackageManifest('demo-pkg', '1.2.3') expect(requestMock).toHaveBeenCalledTimes(2) - expect(result.size).toBe(2) - expect(result.get('demo-a')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) - expect(result.get('demo-b')).toEqual({ - latestVersion: '1.2.3', - allVersions: ['1.2.3'], - }) - expect(consoleErrorSpy).toHaveBeenCalledTimes(1) - consoleErrorSpy.mockRestore() - }) - - it('continues fetching when batch callback throws', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - requestMock.mockResolvedValue({ - statusCode: 200, - body: { - text: async () => JSON.stringify({ version: '1.2.3' }), - }, + expect(result).toEqual({ + name: 'demo-pkg', + version: '1.2.3', }) - - const result = await getAllPackageDataFromJsdelivr( - ['demo-a', 'demo-b', 'demo-c', 'demo-d', 'demo-e', 'demo-f'], - undefined, - undefined, - () => { - throw new Error('batch callback failed') - } - ) - - expect(requestMock).toHaveBeenCalledTimes(6) - expect(result.size).toBe(6) - expect(consoleErrorSpy).toHaveBeenCalledTimes(1) - consoleErrorSpy.mockRestore() }) }) diff --git a/test/unit/services/jsdelivr-registry.test.ts b/test/unit/services/jsdelivr-registry.test.ts index c07f5e0..cf4341e 100644 --- a/test/unit/services/jsdelivr-registry.test.ts +++ b/test/unit/services/jsdelivr-registry.test.ts @@ -1,149 +1,31 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { - getAllPackageDataFromJsdelivr, - clearJsdelivrPackageCache, -} from '../../../src/services/jsdelivr-registry' -import { persistentCache } from '../../../src/services/persistent-cache' +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' +import { fetchExactPackageManifest } from '../../../src/services/jsdelivr-registry' import { PACKAGE_NAME } from '../../../src/config/constants' describe('jsdelivr-registry', () => { - const isSemverOrUnknown = (value: string | undefined): boolean => - value === 'unknown' || /^\d+\.\d+\.\d+$/.test(value ?? '') + const packageVersion = JSON.parse( + readFileSync(join(process.cwd(), 'package.json'), 'utf-8') + ).version as string - beforeEach(() => { - clearJsdelivrPackageCache() - persistentCache.clearCache() - }) - - describe('getAllPackageDataFromJsdelivr()', () => { - it('should fetch package data for inup from jsdelivr', async () => { - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - - expect(result.size).toBe(1) - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - expect(isSemverOrUnknown(inupData?.latestVersion)).toBe(true) - expect(inupData?.allVersions).toBeDefined() - if (inupData?.latestVersion === 'unknown') { - expect(inupData.allVersions.length).toBe(0) - } else { - expect(inupData?.allVersions.length).toBeGreaterThan(0) - } - }, 10000) - - it('should fetch both latest and major versions for inup', async () => { - const currentVersions = new Map([[PACKAGE_NAME, '1.0.0']]) - - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME], currentVersions) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - expect(isSemverOrUnknown(inupData?.latestVersion)).toBe(true) - - // Network-unavailable fallback may return unknown + empty versions. - if (inupData?.latestVersion === 'unknown') { - expect(inupData.allVersions.length).toBe(0) - } else { - expect(inupData?.allVersions.length).toBeGreaterThanOrEqual(1) - } - }, 10000) - - it('should not duplicate versions when major equals latest', async () => { - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - - // Check that all versions are unique - const uniqueVersions = new Set(inupData?.allVersions) - expect(uniqueVersions.size).toBe(inupData?.allVersions.length) - }, 10000) - - it('should return empty map for empty input', async () => { - const result = await getAllPackageDataFromJsdelivr([]) - - expect(result.size).toBe(0) - }) - - it('should cache package data for inup', async () => { - const start1 = Date.now() - await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - const duration1 = Date.now() - start1 + it('fetches an exact pinned package manifest from jsdelivr', async () => { + const manifest = await fetchExactPackageManifest(PACKAGE_NAME, packageVersion) - const start2 = Date.now() - await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - const duration2 = Date.now() - start2 + expect(manifest).not.toBeNull() + expect(manifest?.name).toBe(PACKAGE_NAME) + expect(manifest?.version).toBe(packageVersion) + }, 10000) - // Second fetch should be near-instant (cached) — allow 1ms floor for timer resolution - expect(duration2).toBeLessThanOrEqual(Math.max(duration1 / 2, 5)) - }, 10000) + it('returns null for non-exact versions', async () => { + const manifest = await fetchExactPackageManifest(PACKAGE_NAME, 'latest') - it('should call progress callback', async () => { - const progressUpdates: Array<{ package: string; completed: number; total: number }> = [] - - await getAllPackageDataFromJsdelivr( - [PACKAGE_NAME, PACKAGE_NAME], - undefined, - (pkg, completed, total) => { - progressUpdates.push({ package: pkg, completed, total }) - } - ) - - expect(progressUpdates.length).toBe(2) - expect(progressUpdates[0].total).toBe(2) - expect(progressUpdates[1].completed).toBe(2) - }, 15000) - - it('should sort versions correctly for inup', async () => { - const currentVersions = new Map([[PACKAGE_NAME, '1.0.0']]) - - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME], currentVersions) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - - // If multiple versions exist, verify they're sorted - if (inupData && inupData.allVersions.length > 1) { - const versions = inupData.allVersions - // First version should be the latest - expect(versions[0]).toBe(inupData.latestVersion) - } - }, 10000) - - it('should extract major version correctly for inup with specific version', async () => { - const currentVersions = new Map([[PACKAGE_NAME, '1.2.0']]) - - const result = await getAllPackageDataFromJsdelivr([PACKAGE_NAME], currentVersions) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - expect(isSemverOrUnknown(inupData?.latestVersion)).toBe(true) - }, 10000) + expect(manifest).toBeNull() }) - describe('clearJsdelivrPackageCache()', () => { - it('should clear the cache for inup', async () => { - // First fetch to populate cache - const result1 = await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - expect(result1.size).toBe(1) - - // Second fetch should be much faster (cached) - const start2 = Date.now() - await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - const cachedDuration = Date.now() - start2 - - // Clear both in-memory and persistent cache - clearJsdelivrPackageCache() - persistentCache.clearCache() - - // Third fetch should hit the network again and not be instant - const start3 = Date.now() - const result3 = await getAllPackageDataFromJsdelivr([PACKAGE_NAME]) - const networkDuration = Date.now() - start3 + it('returns null for empty versions', async () => { + const manifest = await fetchExactPackageManifest(PACKAGE_NAME, '') - // Verify cache was actually cleared by checking network fetch took longer than cached fetch - expect(result3.size).toBe(1) - expect(networkDuration).toBeGreaterThanOrEqual(cachedDuration) - }, 15000) + expect(manifest).toBeNull() }) }) diff --git a/test/unit/services/npm-registry.test.ts b/test/unit/services/npm-registry.test.ts index cf569fc..f3fd2bd 100644 --- a/test/unit/services/npm-registry.test.ts +++ b/test/unit/services/npm-registry.test.ts @@ -1,116 +1,181 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +const { getAllPackageDataFromJsdelivrMock } = vi.hoisted(() => ({ + getAllPackageDataFromJsdelivrMock: vi.fn(), +})) + +vi.mock('../../../src/services/jsdelivr-registry', () => ({ + getAllPackageDataFromJsdelivr: getAllPackageDataFromJsdelivrMock, +})) + import { getAllPackageData, clearPackageCache } from '../../../src/services/npm-registry' -import { persistentCache } from '../../../src/services/persistent-cache' -import { PACKAGE_NAME } from '../../../src/config/constants' describe('npm-registry', () => { + const fetchMock = vi.fn() + beforeEach(() => { clearPackageCache() - persistentCache.clearCache() + fetchMock.mockReset() + getAllPackageDataFromJsdelivrMock.mockReset() + vi.stubGlobal('fetch', fetchMock) }) - describe('getAllPackageData()', () => { - it('should fetch package data for inup from npm registry', async () => { - const result = await getAllPackageData([PACKAGE_NAME]) - - expect(result.size).toBe(1) - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - expect(inupData?.latestVersion).toMatch(/^\d+\.\d+\.\d+$/) - expect(inupData?.allVersions).toBeDefined() - expect(inupData?.allVersions.length).toBeGreaterThan(0) - }, 10000) - - it('should filter out pre-release versions for inup', async () => { - const result = await getAllPackageData([PACKAGE_NAME]) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - - // All versions should be stable (X.Y.Z format, no -beta, -rc, etc.) - inupData?.allVersions.forEach((version) => { - expect(version).toMatch(/^\d+\.\d+\.\d+$/) - expect(version).not.toContain('-') - expect(version).not.toContain('alpha') - expect(version).not.toContain('beta') - expect(version).not.toContain('rc') - }) - }, 10000) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches version data from npm registry', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ + versions: { + '1.0.0': {}, + '1.2.0': {}, + '2.0.0-beta.1': {}, + '1.1.0': {}, + }, + }), + }) - it('should return empty map for empty input', async () => { - const result = await getAllPackageData([]) + const result = await getAllPackageData(['demo-pkg']) - expect(result.size).toBe(0) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(result.get('demo-pkg')).toEqual({ + latestVersion: '1.2.0', + allVersions: ['1.2.0', '1.1.0', '1.0.0'], }) + }) - it('should cache package data for inup', async () => { - const start1 = Date.now() - await getAllPackageData([PACKAGE_NAME]) - const duration1 = Date.now() - start1 + it('returns empty map for empty input', async () => { + const result = await getAllPackageData([]) - const start2 = Date.now() - await getAllPackageData([PACKAGE_NAME]) - const duration2 = Date.now() - start2 + expect(result.size).toBe(0) + expect(fetchMock).not.toHaveBeenCalled() + }) - // Second fetch should be significantly faster (cached) - expect(duration2).toBeLessThan(duration1 / 2) - }, 10000) + it('coalesces duplicate in-flight lookups within a run', async () => { + let resolveFetch: ((value: { ok: boolean; text: () => Promise }) => void) | undefined + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve + }) + ) + + const pending = getAllPackageData(['demo-pkg', 'demo-pkg']) + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveFetch?.({ + ok: true, + text: async () => + JSON.stringify({ + versions: { + '1.0.0': {}, + '1.1.0': {}, + }, + }), + }) - it('should call progress callback', async () => { - const progressUpdates: Array<{ package: string; completed: number; total: number }> = [] + const result = await pending + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(result.get('demo-pkg')).toEqual({ + latestVersion: '1.1.0', + allVersions: ['1.1.0', '1.0.0'], + }) + }) - await getAllPackageData([PACKAGE_NAME, PACKAGE_NAME], (pkg, completed, total) => { - progressUpdates.push({ package: pkg, completed, total }) + it('fetches fresh data again on a later call', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {} } }), + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.1.0': {}, '1.0.0': {} } }), }) - expect(progressUpdates.length).toBe(2) - expect(progressUpdates[0].total).toBe(2) - expect(progressUpdates[0].completed).toBe(1) - expect(progressUpdates[1].completed).toBe(2) - }, 10000) - - it('should sort versions correctly for inup', async () => { - const result = await getAllPackageData([PACKAGE_NAME]) - - const inupData = result.get(PACKAGE_NAME) - expect(inupData).toBeDefined() - - // Versions should be sorted in descending order - if (inupData && inupData.allVersions.length > 1) { - const versions = inupData.allVersions - // First version should be the latest - expect(versions[0]).toBe(inupData.latestVersion) - - // Verify descending order - for (let i = 0; i < versions.length - 1; i++) { - const current = versions[i].split('.').map(Number) - const next = versions[i + 1].split('.').map(Number) - - // Current should be >= next - const currentNum = current[0] * 10000 + current[1] * 100 + current[2] - const nextNum = next[0] * 10000 + next[1] * 100 + next[2] - expect(currentNum).toBeGreaterThanOrEqual(nextNum) - } + const first = await getAllPackageData(['demo-pkg']) + const second = await getAllPackageData(['demo-pkg']) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(first.get('demo-pkg')?.latestVersion).toBe('1.0.0') + expect(second.get('demo-pkg')?.latestVersion).toBe('1.1.0') + }) + + it('returns unknown for failed packages without aborting the batch', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('good-pkg')) { + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {}, '1.1.0': {} } }), + }) } - }, 10000) + + return Promise.resolve({ + ok: false, + status: 404, + text: async () => '', + }) + }) + + const result = await getAllPackageData(['good-pkg', 'bad-pkg']) + + expect(result.get('good-pkg')).toEqual({ + latestVersion: '1.1.0', + allVersions: ['1.1.0', '1.0.0'], + }) + expect(result.get('bad-pkg')).toEqual({ + latestVersion: 'unknown', + allVersions: [], + }) }) - describe('clearPackageCache()', () => { - it('should clear the cache for inup', async () => { - // First fetch - await getAllPackageData([PACKAGE_NAME]) + it('falls back to jsdelivr when npm responds with a retryable status', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 429, + text: async () => '', + }) + getAllPackageDataFromJsdelivrMock.mockResolvedValue( + new Map([ + [ + 'demo-pkg', + { + latestVersion: '1.1.0', + allVersions: ['1.1.0', '1.0.0'], + }, + ], + ]) + ) + + const currentVersions = new Map([['demo-pkg', '1.0.0']]) + const result = await getAllPackageData(['demo-pkg'], undefined, currentVersions) + + expect(getAllPackageDataFromJsdelivrMock).toHaveBeenCalledWith( + ['demo-pkg'], + new Map([['demo-pkg', '1.0.0']]) + ) + expect(result.get('demo-pkg')).toEqual({ + latestVersion: '1.1.0', + allVersions: ['1.1.0', '1.0.0'], + }) + }) - // Clear both in-memory and persistent cache - clearPackageCache() - persistentCache.clearCache() + it('calls progress callback once per requested package', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {} } }), + }) + const progressUpdates: Array<{ package: string; completed: number; total: number }> = [] - // Second fetch should hit the network again - const start = Date.now() - await getAllPackageData([PACKAGE_NAME]) - const duration = Date.now() - start + await getAllPackageData(['demo-pkg', 'demo-pkg'], (pkg, completed, total) => { + progressUpdates.push({ package: pkg, completed, total }) + }) - // Should take some time (not instant from cache) - expect(duration).toBeGreaterThan(10) - }, 10000) + expect(progressUpdates).toEqual([ + { package: 'demo-pkg', completed: 1, total: 2 }, + { package: 'demo-pkg', completed: 2, total: 2 }, + ]) }) }) From 1b0311a8447183ed2502e925a920187b3363ef08 Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Thu, 19 Mar 2026 17:32:24 +0200 Subject: [PATCH 2/2] style: format service files --- src/services/changelog-fetcher.ts | 5 ++++- src/services/jsdelivr-registry.ts | 8 +++++--- src/services/npm-registry.ts | 8 +++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/services/changelog-fetcher.ts b/src/services/changelog-fetcher.ts index e64b954..8e875db 100644 --- a/src/services/changelog-fetcher.ts +++ b/src/services/changelog-fetcher.ts @@ -39,7 +39,10 @@ export class ChangelogFetcher { * Fetch package metadata from npm registry * Uses a cached approach to avoid repeated requests */ - async fetchPackageMetadata(packageName: string, version?: string): Promise { + async fetchPackageMetadata( + packageName: string, + version?: string + ): Promise { const cacheKey = this.getCacheKey(packageName, version) // Check if we already have this in cache diff --git a/src/services/jsdelivr-registry.ts b/src/services/jsdelivr-registry.ts index 675d5cf..ed3d9ef 100644 --- a/src/services/jsdelivr-registry.ts +++ b/src/services/jsdelivr-registry.ts @@ -358,9 +358,11 @@ export async function fetchExactPackageManifest( return await inFlight } - const lookupPromise = fetchPackageManifestFromJsdelivr(packageName, normalizedVersion).finally(() => { - inFlightManifests.delete(cacheKey) - }) + const lookupPromise = fetchPackageManifestFromJsdelivr(packageName, normalizedVersion).finally( + () => { + inFlightManifests.delete(cacheKey) + } + ) inFlightManifests.set(cacheKey, lookupPromise) return await lookupPromise } diff --git a/src/services/npm-registry.ts b/src/services/npm-registry.ts index 1f0fceb..69be6c4 100644 --- a/src/services/npm-registry.ts +++ b/src/services/npm-registry.ts @@ -51,9 +51,11 @@ async function getFreshPackageData( return await inFlight } - const lookupPromise = fetchPackageFromRegistryWithFallback(packageName, currentVersion).finally(() => { - inFlightLookups.delete(cacheKey) - }) + const lookupPromise = fetchPackageFromRegistryWithFallback(packageName, currentVersion).finally( + () => { + inFlightLookups.delete(cacheKey) + } + ) inFlightLookups.set(cacheKey, lookupPromise) return await lookupPromise }