diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index f3156a7..0d16ac2 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -1,5 +1,13 @@ import * as semver from 'semver' -import { PackageInfo, PackageJson, UpgradeOptions } from '../types' +import { + DependencyEntry, + PackageInfo, + PackageLoadProgress, + StreamOutdatedPackagesBatchItem, + StreamOutdatedPackagesCallback, + StreamOutdatedPackagesInitialPayload, + UpgradeOptions, +} from '../types' import { findPackageJson, readPackageJson, @@ -7,18 +15,26 @@ import { collectAllDependenciesAsync, findClosestMinorVersion, } from '../utils' -import { getAllPackageData } from '../services' +import { getAllPackageDataBatched, PackageVersionData } from '../services' import { isPackageIgnored } from '../config' import { ConsoleUtils } from '../ui/utils' import { debugLog } from '../utils' +interface PreparedDependencies { + allDependencies: DependencyEntry[] + uniquePackages: string[] + currentVersions: Map +} + export class PackageDetector { private packageJsonPath: string | null = null - private packageJson: PackageJson | null = null + private packageJson: Record | null = null private cwd: string private excludePatterns: string[] private ignorePackages: string[] private maxDepth: number + private readonly batchSizes = [10, 15, 20, 25] + private readonly batchConcurrency = 5 constructor(options?: UpgradeOptions) { this.cwd = options?.cwd || process.cwd() @@ -36,18 +52,132 @@ export class PackageDetector { } public async getOutdatedPackages(): Promise { + const packages: PackageInfo[] = [] + + await this.streamOutdatedPackages((event) => { + if (event.type === 'batch') { + event.payload.batch.forEach((item) => { + packages.push(...item.packageInfo) + }) + } else if (event.type === 'complete') { + packages.splice(0, packages.length, ...event.payload.packages) + } + }) + + return packages + } + + public async streamOutdatedPackages( + onEvent: StreamOutdatedPackagesCallback + ): Promise { if (!this.packageJson) { throw new Error('No package.json found in current directory') } - const packages: PackageInfo[] = [] const t0 = Date.now() debugLog.info('PackageDetector', `Starting scan in ${this.cwd}`) - // Always check all package.json files recursively with timeout protection + const prepared = await this.prepareDependencies() + const initialPayload: StreamOutdatedPackagesInitialPayload = { + allDependencies: prepared.allDependencies, + uniquePackages: prepared.uniquePackages, + currentVersions: prepared.currentVersions, + progress: this.createProgressSnapshot(prepared.uniquePackages.length, 0, 0, true), + } + + onEvent({ type: 'initial', payload: initialPayload }) + + const packageLookup = new Map() + let resolved = 0 + let failed = 0 + + const tFetch = Date.now() + debugLog.info('PackageDetector', 'fetching version data via npm registry in batches') + + await getAllPackageDataBatched( + prepared.uniquePackages, + (batch) => { + const batchItems: StreamOutdatedPackagesBatchItem[] = batch.map((batchItem) => { + const packageInfo = this.resolvePackageGroup( + batchItem.packageName, + prepared.allDependencies, + batchItem.data + ) + packageLookup.set(batchItem.packageName, packageInfo) + resolved++ + + const isFailed = batchItem.data.latestVersion === 'unknown' + if (isFailed) { + failed++ + } + + return { + packageName: batchItem.packageName, + packageInfo, + failed: isFailed, + } + }) + + const progress = this.createProgressSnapshot( + prepared.uniquePackages.length, + resolved, + failed, + resolved < prepared.uniquePackages.length + ) + + onEvent({ + type: 'batch', + payload: { + batch: batchItems, + progress, + }, + }) + }, + prepared.currentVersions, + { + batchSizes: this.batchSizes, + concurrency: this.batchConcurrency, + } + ) + + debugLog.perf( + 'PackageDetector', + `registry fetch (${resolved}/${prepared.uniquePackages.length} resolved)`, + tFetch + ) + + const finalPackages = prepared.uniquePackages.flatMap( + (packageName) => packageLookup.get(packageName) ?? [] + ) + const progress = this.createProgressSnapshot( + prepared.uniquePackages.length, + resolved, + failed, + false + ) + + debugLog.perf( + 'PackageDetector', + `total scan complete (${finalPackages.filter((p) => p.isOutdated).length} outdated of ${finalPackages.length} deps)`, + t0 + ) + + onEvent({ + type: 'complete', + payload: { + packages: finalPackages, + progress, + }, + }) + + ConsoleUtils.clearProgress() + return finalPackages + } + + private async prepareDependencies(): Promise { this.showProgress('🔍 Scanning repository for package.json files...') const tScan = Date.now() - const allPackageJsonFiles = await this.findPackageJsonFilesWithTimeout(30000) // 30 second timeout + const allPackageJsonFiles = await this.findPackageJsonFilesWithTimeout(30000) debugLog.perf('PackageDetector', `file scan (${allPackageJsonFiles.length} files)`, tScan, { files: allPackageJsonFiles, }) @@ -55,7 +185,6 @@ export class PackageDetector { `🔍 Found ${allPackageJsonFiles.length} package.json file${allPackageJsonFiles.length === 1 ? '' : 's'}` ) - // Step 2: Collect all dependencies from package.json files (parallelized) this.showProgress('🔍 Reading dependencies from package.json files...') const tDeps = Date.now() const allDepsRaw = await collectAllDependenciesAsync(allPackageJsonFiles, { @@ -64,13 +193,13 @@ export class PackageDetector { }) debugLog.perf('PackageDetector', `dependency collection (${allDepsRaw.length} raw deps)`, tDeps) - // Step 3: Get unique package names while filtering out workspace references and ignored packages this.showProgress('🔍 Identifying unique packages...') const uniquePackageNames = new Set() - const allDeps: typeof allDepsRaw = [] + const allDependencies: DependencyEntry[] = [] let ignoredCount = 0 const seenWorkspaceRefs = new Set() const seenIgnored = new Set() + for (const dep of allDepsRaw) { if (this.isWorkspaceReference(dep.version)) { const key = `${dep.name}@${dep.version}` @@ -80,6 +209,7 @@ export class PackageDetector { } continue } + if (this.ignorePackages.length > 0 && isPackageIgnored(dep.name, this.ignorePackages)) { ignoredCount++ if (!seenIgnored.has(dep.name)) { @@ -88,128 +218,141 @@ export class PackageDetector { } continue } - allDeps.push(dep) + + allDependencies.push({ + name: dep.name, + version: dep.version, + type: dep.type as DependencyEntry['type'], + packageJsonPath: dep.packageJsonPath, + }) uniquePackageNames.add(dep.name) } + if (ignoredCount > 0) { this.showProgress(`🔍 Skipped ${ignoredCount} ignored package(s)`) } - const packageNames = Array.from(uniquePackageNames) + + const uniquePackages = Array.from(uniquePackageNames).sort((a, b) => { + const aIsScoped = a.startsWith('@') + const bIsScoped = b.startsWith('@') + if (aIsScoped && !bIsScoped) return -1 + if (!aIsScoped && bIsScoped) return 1 + return a.localeCompare(b) + }) + debugLog.info( 'PackageDetector', - `${packageNames.length} unique packages to check, ${ignoredCount} ignored` + `${uniquePackages.length} unique packages to check, ${ignoredCount} ignored` ) const currentVersions = new Map() - for (const dep of allDeps) { + for (const dep of allDependencies) { if (!currentVersions.has(dep.name)) { currentVersions.set(dep.name, dep.version) } } - const tFetch = Date.now() - 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)`, - tFetch - ) + return { + allDependencies, + uniquePackages, + currentVersions, + } + } - const loggedOutdated = new Set() + private resolvePackageGroup( + packageName: string, + allDependencies: DependencyEntry[], + packageData: PackageVersionData | undefined + ): PackageInfo[] { + const dependencies = allDependencies.filter((dep) => dep.name === packageName) const loggedNoData = new Set() - try { - for (const dep of allDeps) { - try { - const packageData = allPackageData.get(dep.name) - if (!packageData) { - if (!loggedNoData.has(dep.name)) { - loggedNoData.add(dep.name) - debugLog.warn('PackageDetector', `no data returned for ${dep.name} — skipping`) - } - continue + const loggedOutdated = new Set() + + return dependencies.map((dep) => { + try { + if (!packageData || packageData.latestVersion === 'unknown') { + if (!loggedNoData.has(dep.name)) { + loggedNoData.add(dep.name) + debugLog.warn( + 'PackageDetector', + `no data returned for ${dep.name} — marking unavailable` + ) } - const { latestVersion, allVersions } = packageData - - // Find closest minor version (same major, higher minor) that satisfies the current range - // Falls back to patch updates if no minor updates are available - const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions) - - const installedClean = semver.coerce(dep.version)?.version || dep.version - const minorClean = closestMinorVersion - ? semver.coerce(closestMinorVersion)?.version || closestMinorVersion - : null - const latestClean = semver.coerce(latestVersion)?.version || latestVersion - - const hasRangeUpdate = minorClean !== null && minorClean !== installedClean - const hasMajorUpdate = semver.major(latestClean) > semver.major(installedClean) - const isOutdated = hasRangeUpdate || hasMajorUpdate - - if (isOutdated) { - const outdatedKey = `${dep.name}@${dep.version}` - if (!loggedOutdated.has(outdatedKey)) { - loggedOutdated.add(outdatedKey) - debugLog.info( - 'PackageDetector', - `outdated: ${dep.name} ${dep.version} → range:${closestMinorVersion ?? '-'} latest:${latestVersion}` - ) - } + return this.createFailedPackageInfo(dep) + } + + const { latestVersion, allVersions } = packageData + const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions) + + const installedClean = semver.coerce(dep.version)?.version || dep.version + const minorClean = closestMinorVersion + ? semver.coerce(closestMinorVersion)?.version || closestMinorVersion + : null + const latestClean = semver.coerce(latestVersion)?.version || latestVersion + + const hasRangeUpdate = minorClean !== null && minorClean !== installedClean + const hasMajorUpdate = + semver.valid(latestClean) !== null && + semver.valid(installedClean) !== null && + semver.major(latestClean) > semver.major(installedClean) + const isOutdated = hasRangeUpdate || hasMajorUpdate + + if (isOutdated) { + const outdatedKey = `${dep.name}@${dep.version}` + if (!loggedOutdated.has(outdatedKey)) { + loggedOutdated.add(outdatedKey) + debugLog.info( + 'PackageDetector', + `outdated: ${dep.name} ${dep.version} → range:${closestMinorVersion ?? '-'} latest:${latestVersion}` + ) } + } - packages.push({ - name: dep.name, - currentVersion: dep.version, // Keep original version specifier with prefix - rangeVersion: closestMinorVersion || dep.version, - latestVersion, - type: dep.type as - | 'dependencies' - | 'devDependencies' - | 'optionalDependencies' - | 'peerDependencies', - packageJsonPath: dep.packageJsonPath, - isOutdated, - hasRangeUpdate, - hasMajorUpdate, - }) - } catch (error) { - debugLog.error('PackageDetector', `error processing ${dep.name}`, error) - // Skip packages that can't be checked (private packages, etc.) - packages.push({ - name: dep.name, - currentVersion: dep.version, - rangeVersion: 'unknown', - latestVersion: 'unknown', - type: dep.type as - | 'dependencies' - | 'devDependencies' - | 'optionalDependencies' - | 'peerDependencies', - packageJsonPath: dep.packageJsonPath, - isOutdated: false, - hasRangeUpdate: false, - hasMajorUpdate: false, - }) + return { + name: dep.name, + currentVersion: dep.version, + rangeVersion: closestMinorVersion || dep.version, + latestVersion, + type: dep.type, + packageJsonPath: dep.packageJsonPath, + isOutdated, + hasRangeUpdate, + hasMajorUpdate, } + } catch (error) { + debugLog.error('PackageDetector', `error processing ${dep.name}`, error) + return this.createFailedPackageInfo(dep) } + }) + } - const outdatedCount = packages.filter((p) => p.isOutdated).length - debugLog.perf( - 'PackageDetector', - `total scan complete (${outdatedCount} outdated of ${packages.length} deps)`, - t0 - ) - return packages - } catch (error) { - this.showProgress('❌ Failed to check packages\n') - debugLog.error('PackageDetector', 'fatal error during package check', error) - throw error + private createFailedPackageInfo(dep: DependencyEntry): PackageInfo { + return { + name: dep.name, + currentVersion: dep.version, + rangeVersion: 'unknown', + latestVersion: 'unknown', + type: dep.type, + packageJsonPath: dep.packageJsonPath, + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + } + } + + private createProgressSnapshot( + total: number, + resolved: number, + failed: number, + isLoading: boolean + ): PackageLoadProgress { + return { + discovered: total, + resolved, + total, + failed, + isLoading, } } @@ -224,7 +367,6 @@ export class PackageDetector { this.excludePatterns, this.maxDepth, (currentDir: string, foundCount: number) => { - // Show scanning progress with current directory and count const truncatedDir = currentDir.length > 50 ? '...' + currentDir.slice(-47) : currentDir this.showProgress(`🔍 Scanning ${truncatedDir} (found ${foundCount})`) @@ -250,7 +392,6 @@ export class PackageDetector { } private isWorkspaceReference(version: string): boolean { - // Check for common workspace reference patterns return ( version.includes('workspace:') || version === '*' || diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index 625f463..cb932bb 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -2,7 +2,13 @@ import chalk from 'chalk' import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' -import { UpgradeOptions, PackageManagerInfo } from '../types' +import { + PackageInfo, + PackageLoadProgress, + PackageSelectionState, + UpgradeOptions, + PackageManagerInfo, +} from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' /** @@ -36,33 +42,86 @@ export class UpgradeRunner { // Check prerequisites this.checkPrerequisites() - // Detect packages - const packages = await this.detector.getOutdatedPackages() + const progress: PackageLoadProgress = { + discovered: 0, + resolved: 0, + total: 0, + failed: 0, + isLoading: true, + } + let selectionStates: PackageSelectionState[] = [] + let refreshUI: (() => void) | undefined + let latestPackages: PackageInfo[] = [] + let previousSelections: Map | undefined + + const selectionPromise = new Promise((resolve, reject) => { + const streamPromise = this.detector.streamOutdatedPackages((event) => { + if (event.type === 'initial') { + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + + selectionStates = [] + + this.ui + .selectPackagesToUpgradeProgressive(selectionStates, progress, (refresh) => { + refreshUI = refresh + }) + .then(resolve) + .catch(reject) + } + + if (event.type === 'batch') { + latestPackages = latestPackages + .filter((pkg) => !event.payload.batch.some((item) => item.packageName === pkg.name)) + .concat(event.payload.batch.flatMap((item) => item.packageInfo)) + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + this.ui.appendOutdatedBatchToSelectionStates( + selectionStates, + event.payload.batch, + previousSelections + ) + refreshUI?.() + } + + if (event.type === 'complete') { + latestPackages = event.payload.packages + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + refreshUI?.() + } + }) - // Display packages table - await this.ui.displayPackagesTable(packages) + streamPromise.catch(reject) + }) - const outdatedPackages = this.detector.getOutdatedPackagesOnly(packages) - if (outdatedPackages.length === 0) { + let selectedChoices: any[] = await selectionPromise + const outdatedPackages = this.detector.getOutdatedPackagesOnly(latestPackages) + if (outdatedPackages.length === 0 && selectedChoices.length === 0) { + console.log(chalk.green('✅ All packages are up to date!')) return } // Interactive selection and confirmation loop - let selectedChoices: any[] = [] let shouldProceed: boolean | null = false - let previousSelections: Map | undefined while (true) { - // Interactive selection - selectedChoices = await this.ui.selectPackagesToUpgrade(packages, previousSelections) - if (selectedChoices.length === 0) { console.log(chalk.yellow('No packages selected. Exiting...')) return } // Validate selected choices before confirmation - this.validateSelectedChoices(selectedChoices, packages) + this.validateSelectedChoices(selectedChoices, latestPackages) // Store current selections for potential return to selection previousSelections = new Map() @@ -85,7 +144,15 @@ export class UpgradeRunner { // User pressed N or ESC - go back to selection with current selections preserved console.clear() console.log(chalk.bold.blue('🚀 inup\n')) - // previousSelections is already set from above + selectedChoices = progress.isLoading + ? await this.ui.selectPackagesToUpgradeProgressive( + selectionStates, + progress, + (refresh) => { + refreshUI = refresh + } + ) + : await this.ui.selectPackagesToUpgrade(latestPackages, previousSelections) continue } @@ -99,7 +166,7 @@ export class UpgradeRunner { } // Perform upgrade - await this.upgrader.upgradePackages(selectedChoices, packages) + await this.upgrader.upgradePackages(selectedChoices, latestPackages) } catch (error) { console.error(chalk.red(`Error: ${error}`)) process.exit(1) diff --git a/src/interactive-ui.ts b/src/interactive-ui.ts index e13ba6d..e89132b 100644 --- a/src/interactive-ui.ts +++ b/src/interactive-ui.ts @@ -3,10 +3,12 @@ import chalk from 'chalk' import * as semver from 'semver' const keypress = require('keypress') import { + PackageLoadProgress, PackageInfo, PackageUpgradeChoice, PackageSelectionState, PackageManagerInfo, + StreamOutdatedPackagesBatchItem, } from './types' import { Key } from 'node:readline' import { @@ -39,88 +41,157 @@ export class InteractiveUI { packages: PackageInfo[], previousSelections?: Map ): Promise { - const outdatedPackages = packages.filter((p) => p.isOutdated) - - if (outdatedPackages.length === 0) { + const selectionStates = this.createSelectionStates(packages, previousSelections, false) + if (selectionStates.length === 0) { return [] } - // Deduplicate packages by name and version specifier, but track all package.json paths - const uniquePackages = new Map< - string, - { - pkg: PackageInfo - packageJsonPaths: Set - type: PackageInfo['type'] - } - >() - - for (const pkg of outdatedPackages) { - const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` - if (!uniquePackages.has(key)) { - uniquePackages.set(key, { - pkg, - packageJsonPaths: new Set([pkg.packageJsonPath]), - type: pkg.type, - }) - } else { - uniquePackages.get(key)!.packageJsonPaths.add(pkg.packageJsonPath) - } - } - - // Convert to array and sort alphabetically by name (@scoped packages first, then unscoped) - const deduplicatedPackages = Array.from(uniquePackages.values()).map( - ({ pkg, packageJsonPaths, type }) => ({ - ...pkg, - packageJsonPaths: Array.from(packageJsonPaths), - type, - }) - ) - - deduplicatedPackages.sort((a, b) => { - const aIsScoped = a.name.startsWith('@') - const bIsScoped = b.name.startsWith('@') - - // If one is scoped and the other isn't, scoped comes first - if (aIsScoped && !bIsScoped) return -1 - if (!aIsScoped && bIsScoped) return 1 + const selectedStates = await this.interactiveTableSelector(selectionStates) + return this.createUpgradeChoices(selectedStates) + } - // Both scoped or both unscoped - sort alphabetically - return a.name.localeCompare(b.name) - }) + public createSelectionStates( + packages: PackageInfo[], + previousSelections?: Map, + includeUpToDate: boolean = true + ): PackageSelectionState[] { + const relevantPackages = includeUpToDate ? packages : packages.filter((p) => p.isOutdated) + const uniquePackages = this.deduplicatePackages(relevantPackages) - // Create selection states for each unique package - const selectionStates: PackageSelectionState[] = deduplicatedPackages.map((pkg) => { + return Array.from(uniquePackages.values()).map(({ pkg, packageJsonPaths }) => { const currentClean = semver.coerce(pkg.currentVersion)?.version || pkg.currentVersion const rangeClean = semver.coerce(pkg.rangeVersion)?.version || pkg.rangeVersion const latestClean = semver.coerce(pkg.latestVersion)?.version || pkg.latestVersion - - // Use previous selection if available, otherwise default to 'none' const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` const previousSelection = previousSelections?.get(key) || 'none' return { name: pkg.name, - packageJsonPath: pkg.packageJsonPaths[0], // Use first path for display - packageJsonPaths: pkg.packageJsonPaths, // Store all paths for upgrading - currentVersionSpecifier: pkg.currentVersion, // Keep original with prefix + packageJsonPath: pkg.packageJsonPath, + packageJsonPaths: Array.from(packageJsonPaths), + currentVersionSpecifier: pkg.currentVersion, currentVersion: currentClean, rangeVersion: rangeClean, latestVersion: latestClean, selectedOption: previousSelection, + loadState: 'ready', hasRangeUpdate: pkg.hasRangeUpdate, hasMajorUpdate: pkg.hasMajorUpdate, type: pkg.type, } }) + } - // Use custom interactive table selector (simplified - no grouping) - const selectedStates = await this.interactiveTableSelector(selectionStates) + public createPendingSelectionStates( + packages: Array>, + previousSelections?: Map + ): PackageSelectionState[] { + const uniquePackages = this.deduplicatePackages( + packages.map((pkg) => ({ + ...pkg, + rangeVersion: pkg.currentVersion, + latestVersion: pkg.currentVersion, + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + })) + ) + + return Array.from(uniquePackages.values()).map(({ pkg, packageJsonPaths }) => { + const currentClean = semver.coerce(pkg.currentVersion)?.version || pkg.currentVersion + const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` + const previousSelection = previousSelections?.get(key) || 'none' + + return { + name: pkg.name, + packageJsonPath: pkg.packageJsonPath, + packageJsonPaths: Array.from(packageJsonPaths), + currentVersionSpecifier: pkg.currentVersion, + currentVersion: currentClean, + rangeVersion: 'loading', + latestVersion: 'loading', + selectedOption: previousSelection, + loadState: 'pending', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: pkg.type, + } + }) + } + + public appendOutdatedBatchToSelectionStates( + selectionStates: PackageSelectionState[], + batch: StreamOutdatedPackagesBatchItem[], + previousSelections?: Map + ): void { + const outdatedStates = this.createSelectionStates( + batch.flatMap((batchItem) => batchItem.packageInfo).filter((pkg) => pkg.isOutdated), + previousSelections, + false + ) + + if (outdatedStates.length === 0) { + return + } + + const seen = new Set( + selectionStates.map((state) => `${state.name}@${state.currentVersionSpecifier}@${state.type}`) + ) + + outdatedStates.forEach((state) => { + const key = `${state.name}@${state.currentVersionSpecifier}@${state.type}` + if (!seen.has(key)) { + selectionStates.push(state) + seen.add(key) + } + }) + } + + public async selectPackagesToUpgradeProgressive( + selectionStates: PackageSelectionState[], + progress: PackageLoadProgress, + attachRefresh: (refresh: () => void) => void + ): Promise { + const selectedStates = await this.interactiveTableSelector( + selectionStates, + progress, + attachRefresh + ) + return this.createUpgradeChoices(selectedStates) + } - // Convert to PackageUpgradeChoice[] - create one choice per package.json path + private deduplicatePackages( + packages: PackageInfo[] + ): Map }> { + const uniquePackages = new Map }>() + + for (const pkg of packages) { + const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` + if (!uniquePackages.has(key)) { + uniquePackages.set(key, { + pkg, + packageJsonPaths: new Set([pkg.packageJsonPath]), + }) + } else { + uniquePackages.get(key)!.packageJsonPaths.add(pkg.packageJsonPath) + } + } + + return new Map( + Array.from(uniquePackages.entries()).sort(([, a], [, b]) => { + const aIsScoped = a.pkg.name.startsWith('@') + const bIsScoped = b.pkg.name.startsWith('@') + if (aIsScoped && !bIsScoped) return -1 + if (!aIsScoped && bIsScoped) return 1 + return a.pkg.name.localeCompare(b.pkg.name) + }) + ) + } + + private createUpgradeChoices(selectedStates: PackageSelectionState[]): PackageUpgradeChoice[] { const choices: PackageUpgradeChoice[] = [] selectedStates - .filter((state) => state.selectedOption !== 'none') + .filter((state) => state.loadState === 'ready' && state.selectedOption !== 'none') .forEach((state) => { const targetVersion = state.selectedOption === 'range' ? state.rangeVersion : state.latestVersion @@ -129,7 +200,6 @@ export class InteractiveUI { targetVersion ) - // Create a choice for each package.json path where this package appears const pathsToUpdate = state.packageJsonPaths || [state.packageJsonPath] pathsToUpdate.forEach((packageJsonPath) => { choices.push({ @@ -155,11 +225,14 @@ export class InteractiveUI { } private async interactiveTableSelector( - selectionStates: PackageSelectionState[] + selectionStates: PackageSelectionState[], + loadingProgress?: PackageLoadProgress, + attachRefresh?: (refresh: () => void) => void ): Promise { return new Promise((resolve) => { - const states = [...selectionStates] + const states = selectionStates const stateManager = new StateManager(0, this.getTerminalHeight()) + let isResolved = false // No grouping needed - packages are already filtered by type // This simplifies scrolling and avoids rendering issues @@ -215,24 +288,26 @@ export class InteractiveUI { // Opening modal - load package info asynchronously stateManager.toggleInfoModal() const currentState = filteredStates[uiState.currentRow] - stateManager.setModalLoading(true) + const canFetchMetadata = currentState?.loadState === 'ready' + stateManager.setModalLoading(canFetchMetadata) renderInterface() - // Fetch metadata asynchronously - 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() - }) + if (currentState && canFetchMetadata) { + 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() @@ -295,6 +370,7 @@ export class InteractiveUI { } const handleConfirm = (selectedStates: PackageSelectionState[]) => { + isResolved = true // Reset terminal colors process.stdout.write(getTerminalResetCode()) CursorUtils.show() @@ -309,6 +385,7 @@ export class InteractiveUI { } const handleCancel = () => { + isResolved = true // Reset terminal colors process.stdout.write(getTerminalResetCode()) CursorUtils.show() @@ -414,7 +491,8 @@ export class InteractiveUI { uiState.filterMode, uiState.filterQuery, states.length, - terminalWidth + terminalWidth, + loadingProgress ) // Print all lines @@ -440,6 +518,12 @@ export class InteractiveUI { // Setup keypress handling try { + attachRefresh?.(() => { + if (!isResolved) { + renderInterface() + } + }) + keypress(process.stdin) if (process.stdin.setRawMode) { process.stdin.setRawMode(true) diff --git a/src/services/npm-registry.ts b/src/services/npm-registry.ts index 69be6c4..bb9afb0 100644 --- a/src/services/npm-registry.ts +++ b/src/services/npm-registry.ts @@ -2,6 +2,7 @@ import * as semver from 'semver' import { NPM_REGISTRY_URL, REQUEST_TIMEOUT } from '../config' import { getAllPackageDataFromJsdelivr } from './jsdelivr-registry' import { ConsoleUtils } from '../ui/utils' +import { OnBatchReadyCallback, RegistryBatchOptions, RegistryBatchProgressItem } from '../types' export interface PackageVersionData { latestVersion: string @@ -160,6 +161,78 @@ export async function getAllPackageData( return packageData } +async function runWithConcurrencyLimit( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) { + return + } + + const limit = Math.max(1, Math.min(concurrency, items.length)) + let nextIndex = 0 + + const runWorker = async (): Promise => { + while (nextIndex < items.length) { + const currentIndex = nextIndex++ + await worker(items[currentIndex], currentIndex) + } + } + + await Promise.all(Array.from({ length: limit }, () => runWorker())) +} + +export async function getAllPackageDataBatched( + packageNames: string[], + onBatchReady?: OnBatchReadyCallback, + currentVersions?: Map, + options: RegistryBatchOptions = {} +): Promise> { + const packageData = new Map() + + if (packageNames.length === 0) { + return packageData + } + + const batchSizes = + options.batchSizes && options.batchSizes.length > 0 + ? options.batchSizes.map((size) => Math.max(1, size)) + : [Math.max(1, options.batchSize ?? 20)] + const concurrency = Math.max(1, options.concurrency ?? 5) + const total = packageNames.length + let completedCount = 0 + let batchStart = 0 + let batchIndex = 0 + + while (batchStart < packageNames.length) { + const batchSize = batchSizes[Math.min(batchIndex, batchSizes.length - 1)] + const batchNames = packageNames.slice(batchStart, batchStart + batchSize) + const batchResults: RegistryBatchProgressItem[] = new Array(batchNames.length) + + await runWithConcurrencyLimit(batchNames, concurrency, async (packageName, itemIndex) => { + const data = await getFreshPackageData(packageName, currentVersions?.get(packageName)) + packageData.set(packageName, data) + completedCount++ + batchResults[itemIndex] = { + packageName, + data, + completed: completedCount, + total, + batchIndex, + itemIndex, + } + }) + + onBatchReady?.(batchResults.filter(Boolean)) + + batchStart += batchSize + batchIndex++ + } + + return packageData +} + /** * Retained for backward compatibility. Registry responses are fresh-by-default. */ diff --git a/src/types.ts b/src/types.ts index 2894824..e0fd4ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,10 +16,25 @@ export interface PackageInfo { license?: string // Package license } +export type DependencyType = + | 'dependencies' + | 'devDependencies' + | 'optionalDependencies' + | 'peerDependencies' + +export interface DependencyEntry { + name: string + version: string + type: DependencyType + packageJsonPath: string +} + +export type PackageLoadState = 'pending' | 'ready' | 'failed' + export interface PackageUpgradeChoice { name: string packageJsonPath: string // Path to the package.json file to upgrade - dependencyType: 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' + dependencyType: DependencyType upgradeType: 'none' | 'range' | 'latest' targetVersion: string currentVersionSpecifier: string // Original version specifier with prefix @@ -34,9 +49,10 @@ export interface PackageSelectionState { rangeVersion: string latestVersion: string selectedOption: 'none' | 'range' | 'latest' + loadState: PackageLoadState hasRangeUpdate: boolean hasMajorUpdate: boolean - type: 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' + type: DependencyType description?: string // Package description from npm registry homepage?: string // Package homepage URL repository?: string // GitHub/repository URL for releases @@ -86,6 +102,53 @@ export interface PackageJson { [key: string]: any } -export type OnBatchReadyCallback = ( - batch: Array<{ name: string; data: { latestVersion: string; allVersions: string[] } }> -) => void +export interface PackageLoadProgress { + discovered: number + resolved: number + total: number + failed: number + isLoading: boolean +} + +export interface StreamOutdatedPackagesInitialPayload { + allDependencies: DependencyEntry[] + uniquePackages: string[] + currentVersions: Map + progress: PackageLoadProgress +} + +export interface StreamOutdatedPackagesBatchItem { + packageName: string + packageInfo: PackageInfo[] + failed: boolean +} + +export type StreamOutdatedPackagesEvent = + | { type: 'initial'; payload: StreamOutdatedPackagesInitialPayload } + | { + type: 'batch' + payload: { + batch: StreamOutdatedPackagesBatchItem[] + progress: PackageLoadProgress + } + } + | { type: 'complete'; payload: { packages: PackageInfo[]; progress: PackageLoadProgress } } + +export type StreamOutdatedPackagesCallback = (event: StreamOutdatedPackagesEvent) => void + +export interface RegistryBatchOptions { + batchSize?: number + batchSizes?: number[] + concurrency?: number +} + +export interface RegistryBatchProgressItem { + packageName: string + data: { latestVersion: string; allVersions: string[] } + completed: number + total: number + batchIndex: number + itemIndex: number +} + +export type OnBatchReadyCallback = (batch: RegistryBatchProgressItem[]) => void diff --git a/src/ui/input-handler.ts b/src/ui/input-handler.ts index 7489ee7..e5db423 100644 --- a/src/ui/input-handler.ts +++ b/src/ui/input-handler.ts @@ -182,7 +182,9 @@ export class InputHandler { case 'return': // Check if any packages are selected - const selectedCount = states.filter((s) => s.selectedOption !== 'none').length + const selectedCount = states.filter( + (s) => s.loadState === 'ready' && s.selectedOption !== 'none' + ).length if (selectedCount === 0) { // Do nothing if no packages selected return diff --git a/src/ui/renderer/index.ts b/src/ui/renderer/index.ts index 0792bc4..b1c71e8 100644 --- a/src/ui/renderer/index.ts +++ b/src/ui/renderer/index.ts @@ -1,4 +1,9 @@ -import { PackageSelectionState, RenderableItem, PackageManagerInfo } from '../../types' +import { + PackageLoadProgress, + PackageSelectionState, + RenderableItem, + PackageManagerInfo, +} from '../../types' import * as PackageList from './package-list' import * as Confirmation from './confirmation' import * as Modal from './modal' @@ -32,7 +37,8 @@ export class UIRenderer { filterMode?: boolean, filterQuery?: string, totalPackagesBeforeFilter?: number, - terminalWidth: number = 80 + terminalWidth: number = 80, + loadingProgress?: PackageLoadProgress ): string[] { return PackageList.renderInterface( states, @@ -46,7 +52,8 @@ export class UIRenderer { filterMode, filterQuery, totalPackagesBeforeFilter, - terminalWidth + terminalWidth, + loadingProgress ) } diff --git a/src/ui/renderer/package-list.ts b/src/ui/renderer/package-list.ts index 2186799..469e0a5 100644 --- a/src/ui/renderer/package-list.ts +++ b/src/ui/renderer/package-list.ts @@ -1,5 +1,10 @@ import chalk from 'chalk' -import { PackageSelectionState, RenderableItem, PackageInfo } from '../../types' +import { + PackageInfo, + PackageLoadProgress, + PackageSelectionState, + RenderableItem, +} from '../../types' import { VersionUtils } from '../utils' import { getThemeColor } from '../themes-colors' @@ -54,6 +59,8 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i const isCurrentSelected = state.selectedOption === 'none' const isRangeSelected = state.selectedOption === 'range' const isLatestSelected = state.selectedOption === 'latest' + const isPending = state.loadState === 'pending' + const isFailed = state.loadState === 'failed' // Current version dot and version (show original specifier with prefix) const currentDot = isCurrentSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') @@ -62,7 +69,13 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Range version dot and version let rangeDot = '' let rangeVersionText = '' - if (state.hasRangeUpdate) { + if (isPending) { + rangeDot = getThemeColor('dotEmpty')('◌') + rangeVersionText = chalk.gray('loading') + } else if (isFailed) { + rangeDot = getThemeColor('dotEmpty')('◌') + rangeVersionText = chalk.gray('unavailable') + } else if (state.hasRangeUpdate) { rangeDot = isRangeSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') const rangeVersionWithPrefix = VersionUtils.applyVersionPrefix( state.currentVersionSpecifier, @@ -77,7 +90,13 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Latest version dot and version let latestDot = '' let latestVersionText = '' - if (state.hasMajorUpdate) { + if (isPending) { + latestDot = getThemeColor('dotEmpty')('◌') + latestVersionText = chalk.gray('loading') + } else if (isFailed) { + latestDot = getThemeColor('dotEmpty')('◌') + latestVersionText = chalk.gray('unavailable') + } else if (state.hasMajorUpdate) { latestDot = isLatestSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') const latestVersionWithPrefix = VersionUtils.applyVersionPrefix( state.currentVersionSpecifier, @@ -137,7 +156,7 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Range version section with dashes only if needed let rangeSection = '' - if (state.hasRangeUpdate) { + if (isPending || isFailed || state.hasRangeUpdate) { rangeSection = `${rangeDot} ${rangeVersionText}` const rangeSectionLength = VersionUtils.getVisualLength(rangeSection) + 1 // +1 for space before padding const rangePadding = Math.max(0, rangeColumnWidth - rangeSectionLength) @@ -150,7 +169,7 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Latest version section with dashes only if needed let latestSection = '' - if (state.hasMajorUpdate) { + if (isPending || isFailed || state.hasMajorUpdate) { latestSection = `${latestDot} ${latestVersionText}` const latestSectionLength = VersionUtils.getVisualLength(latestSection) + 1 // +1 for space before padding const latestPadding = Math.max(0, latestColumnWidth - latestSectionLength) @@ -198,7 +217,8 @@ export function renderInterface( filterMode?: boolean, filterQuery?: string, totalPackagesBeforeFilter?: number, - terminalWidth: number = 80 + terminalWidth: number = 80, + loadingProgress?: PackageLoadProgress ): string[] { const output: string[] = [] @@ -390,6 +410,18 @@ export function renderInterface( } } + if (loadingProgress?.isLoading) { + const loadingLabel = `Loading packages... (${loadingProgress.resolved}/${loadingProgress.total} checked)` + const failedLabel = + loadingProgress.failed > 0 ? ` ${loadingProgress.failed} unavailable` : '' + const loadingLine = + ' ' + + getThemeColor('textSecondary')(loadingLabel) + + (failedLabel ? chalk.yellow(failedLabel) : '') + const loadingPadding = Math.max(0, terminalWidth - VersionUtils.getVisualLength(loadingLine)) + output.push(loadingLine + ' '.repeat(loadingPadding)) + } + return output } diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index 635b6f9..e7ef943 100644 --- a/src/ui/state/state-manager.ts +++ b/src/ui/state/state-manager.ts @@ -114,7 +114,7 @@ export class StateManager { const currentRow = this.navigationManager.getCurrentRow() const currentState = states[currentRow] - if (!currentState) return + if (!currentState || currentState.loadState !== 'ready') return if (direction === 'left') { // Move selection left with wraparound: latest -> range -> none -> latest @@ -159,7 +159,7 @@ export class StateManager { bulkSelectMinor(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - if (state.hasRangeUpdate) { + if (state.loadState === 'ready' && state.hasRangeUpdate) { state.selectedOption = 'range' } }) @@ -168,9 +168,9 @@ export class StateManager { bulkSelectLatest(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - if (state.hasMajorUpdate) { + if (state.loadState === 'ready' && state.hasMajorUpdate) { state.selectedOption = 'latest' - } else if (state.hasRangeUpdate) { + } else if (state.loadState === 'ready' && state.hasRangeUpdate) { state.selectedOption = 'range' } }) @@ -179,7 +179,9 @@ export class StateManager { bulkUnselectAll(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - state.selectedOption = 'none' + if (state.loadState === 'ready') { + state.selectedOption = 'none' + } }) } diff --git a/test/unit/core/package-detector.test.ts b/test/unit/core/package-detector.test.ts new file mode 100644 index 0000000..f4077e3 --- /dev/null +++ b/test/unit/core/package-detector.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + findPackageJson: vi.fn(), + readPackageJson: vi.fn(), + findAllPackageJsonFilesAsync: vi.fn(), + collectAllDependenciesAsync: vi.fn(), + findClosestMinorVersion: vi.fn(), + getAllPackageDataBatched: vi.fn(), +})) + +vi.mock('../../../src/utils', () => ({ + findPackageJson: mocks.findPackageJson, + readPackageJson: mocks.readPackageJson, + findAllPackageJsonFilesAsync: mocks.findAllPackageJsonFilesAsync, + collectAllDependenciesAsync: mocks.collectAllDependenciesAsync, + findClosestMinorVersion: mocks.findClosestMinorVersion, + debugLog: { + info: vi.fn(), + perf: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../../../src/services', () => ({ + getAllPackageDataBatched: mocks.getAllPackageDataBatched, +})) + +vi.mock('../../../src/config', () => ({ + isPackageIgnored: vi.fn(() => false), +})) + +vi.mock('../../../src/ui/utils', () => ({ + ConsoleUtils: { + showProgress: vi.fn(), + clearProgress: vi.fn(), + }, +})) + +import { PackageDetector } from '../../../src/core/package-detector' + +describe('PackageDetector streaming', () => { + beforeEach(() => { + mocks.findPackageJson.mockReturnValue('/repo/package.json') + mocks.readPackageJson.mockReturnValue({ name: 'fixture' }) + mocks.findAllPackageJsonFilesAsync.mockResolvedValue(['/repo/package.json']) + mocks.collectAllDependenciesAsync.mockResolvedValue([ + { + name: 'zod', + version: '^3.0.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + }, + { + name: '@scope/pkg', + version: '^1.0.0', + type: 'devDependencies', + packageJsonPath: '/repo/package.json', + }, + ]) + mocks.findClosestMinorVersion.mockImplementation((version: string, versions: string[]) => versions[0] ?? version) + mocks.getAllPackageDataBatched.mockImplementation( + async ( + packageNames: string[], + onBatchReady: (batch: any[]) => void, + _currentVersions: Map, + options: { batchSizes: number[]; concurrency: number } + ) => { + expect(packageNames).toEqual(['@scope/pkg', 'zod']) + expect(options).toEqual({ batchSizes: [10, 15, 20, 25], concurrency: 5 }) + + onBatchReady([ + { + packageName: '@scope/pkg', + data: { latestVersion: '2.0.0', allVersions: ['1.2.0', '1.0.0'] }, + completed: 1, + total: 2, + batchIndex: 0, + itemIndex: 0, + }, + ]) + + onBatchReady([ + { + packageName: 'zod', + data: { latestVersion: 'unknown', allVersions: [] }, + completed: 2, + total: 2, + batchIndex: 0, + itemIndex: 1, + }, + ]) + + return new Map([ + ['@scope/pkg', { latestVersion: '2.0.0', allVersions: ['1.2.0', '1.0.0'] }], + ['zod', { latestVersion: 'unknown', allVersions: [] }], + ]) + } + ) + }) + + it('emits initial, batch, and complete events in stable order', async () => { + const detector = new PackageDetector({ cwd: '/repo' }) + const eventTypes: string[] = [] + const batchPackageNames: string[][] = [] + + const packages = await detector.streamOutdatedPackages((event) => { + eventTypes.push(event.type) + + if (event.type === 'initial') { + expect(event.payload.uniquePackages).toEqual(['@scope/pkg', 'zod']) + expect(event.payload.progress).toMatchObject({ + total: 2, + resolved: 0, + isLoading: true, + }) + } + + if (event.type === 'batch') { + batchPackageNames.push(event.payload.batch.map((item) => item.packageName)) + } + + if (event.type === 'complete') { + expect(event.payload.progress).toMatchObject({ + total: 2, + resolved: 2, + failed: 1, + isLoading: false, + }) + } + }) + + expect(eventTypes).toEqual(['initial', 'batch', 'batch', 'complete']) + expect(batchPackageNames).toEqual([['@scope/pkg'], ['zod']]) + expect(packages.map((pkg) => pkg.name)).toEqual(['@scope/pkg', 'zod']) + expect(packages[0]).toMatchObject({ + name: '@scope/pkg', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, + }) + expect(packages[1]).toMatchObject({ + name: 'zod', + latestVersion: 'unknown', + isOutdated: false, + }) + }) + + it('keeps getOutdatedPackages compatible with the streamed implementation', async () => { + const detector = new PackageDetector({ cwd: '/repo' }) + const packages = await detector.getOutdatedPackages() + + expect(packages).toHaveLength(2) + expect(packages[0].name).toBe('@scope/pkg') + expect(packages[1].name).toBe('zod') + }) +}) diff --git a/test/unit/services/npm-registry.test.ts b/test/unit/services/npm-registry.test.ts index f3fd2bd..209741a 100644 --- a/test/unit/services/npm-registry.test.ts +++ b/test/unit/services/npm-registry.test.ts @@ -7,7 +7,11 @@ vi.mock('../../../src/services/jsdelivr-registry', () => ({ getAllPackageDataFromJsdelivr: getAllPackageDataFromJsdelivrMock, })) -import { getAllPackageData, clearPackageCache } from '../../../src/services/npm-registry' +import { + clearPackageCache, + getAllPackageData, + getAllPackageDataBatched, +} from '../../../src/services/npm-registry' describe('npm-registry', () => { const fetchMock = vi.fn() @@ -178,4 +182,61 @@ describe('npm-registry', () => { { package: 'demo-pkg', completed: 2, total: 2 }, ]) }) + + it('emits batched results in request order', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('pkg-a')) { + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {}, '1.1.0': {} } }), + }) + } + + if (url.includes('pkg-b')) { + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '2.0.0': {}, '2.1.0': {} } }), + }) + } + + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '3.0.0': {}, '3.1.0': {} } }), + }) + }) + + const batches: string[][] = [] + const result = await getAllPackageDataBatched( + ['pkg-a', 'pkg-b', 'pkg-c'], + (batch) => { + batches.push(batch.map((item) => item.packageName)) + }, + undefined, + { batchSize: 2, concurrency: 1 } + ) + + expect(batches).toEqual([['pkg-a', 'pkg-b'], ['pkg-c']]) + expect(Array.from(result.keys())).toEqual(['pkg-a', 'pkg-b', 'pkg-c']) + }) + + it('supports a growing batch-size sequence', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {}, '1.1.0': {} } }), + }) + + const packageNames = Array.from({ length: 50 }, (_, index) => `pkg-${index + 1}`) + const batches: number[] = [] + + await getAllPackageDataBatched( + packageNames, + (batch) => { + batches.push(batch.length) + }, + undefined, + { batchSizes: [10, 15, 20, 25], concurrency: 5 } + ) + + expect(batches).toEqual([10, 15, 20, 5]) + }) }) diff --git a/test/unit/ui/package-list.test.ts b/test/unit/ui/package-list.test.ts new file mode 100644 index 0000000..07a1224 --- /dev/null +++ b/test/unit/ui/package-list.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { renderPackageLine } from '../../../src/ui/renderer/package-list' +import { PackageSelectionState } from '../../../src/types' + +const baseState: PackageSelectionState = { + name: 'demo-pkg', + packageJsonPath: '/repo/package.json', + packageJsonPaths: ['/repo/package.json'], + currentVersionSpecifier: '^1.0.0', + currentVersion: '1.0.0', + rangeVersion: '1.1.0', + latestVersion: '2.0.0', + selectedOption: 'none', + loadState: 'ready', + hasRangeUpdate: true, + hasMajorUpdate: true, + type: 'dependencies', +} + +describe('package-list renderer', () => { + it('renders pending rows with loading placeholders', () => { + const line = renderPackageLine( + { + ...baseState, + loadState: 'pending', + rangeVersion: 'loading', + latestVersion: 'loading', + hasRangeUpdate: false, + hasMajorUpdate: false, + }, + 0, + true, + 120 + ) + + expect(line).toContain('loading') + }) + + it('renders failed rows as unavailable and keeps layout stable', () => { + const line = renderPackageLine( + { + ...baseState, + loadState: 'failed', + rangeVersion: 'unknown', + latestVersion: 'unknown', + hasRangeUpdate: false, + hasMajorUpdate: false, + }, + 0, + false, + 120 + ) + + expect(line).toContain('unavailable') + }) +})