diff --git a/package.json b/package.json index f1f5ced9ff5b..36b31bc575d8 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "@types/browser-sync": "^2.27.0", "@types/express": "~5.0.1", "@types/http-proxy": "^1.17.4", - "@types/ini": "^4.0.0", "@types/jasmine": "~6.0.0", "@types/jasmine-reporters": "^2", "@types/karma": "^6.3.0", @@ -87,14 +86,12 @@ "@types/lodash": "^4.17.0", "@types/node": "^22.12.0", "@types/npm-package-arg": "^6.1.0", - "@types/pacote": "^11.1.3", "@types/picomatch": "^4.0.0", "@types/progress": "^2.0.3", "@types/semver": "^7.3.12", "@types/watchpack": "^2.4.4", "@types/yargs": "^17.0.20", "@types/yargs-parser": "^21.0.0", - "@types/yarnpkg__lockfile": "^1.1.5", "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "ajv": "8.20.0", diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index b73ed5fba5fe..613785018402 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -61,24 +61,18 @@ ts_project( ":node_modules/@inquirer/prompts", ":node_modules/@listr2/prompt-adapter-inquirer", ":node_modules/@modelcontextprotocol/sdk", - ":node_modules/@yarnpkg/lockfile", ":node_modules/algoliasearch", - ":node_modules/ini", ":node_modules/jsonc-parser", ":node_modules/listr2", ":node_modules/npm-package-arg", - ":node_modules/pacote", ":node_modules/parse5-html-rewriting-stream", ":node_modules/yargs", ":node_modules/zod", "//:node_modules/@angular/core", - "//:node_modules/@types/ini", "//:node_modules/@types/node", "//:node_modules/@types/npm-package-arg", - "//:node_modules/@types/pacote", "//:node_modules/@types/semver", "//:node_modules/@types/yargs", - "//:node_modules/@types/yarnpkg__lockfile", "//:node_modules/semver", "//:node_modules/typescript", ], diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 9a22e424e4b5..680fc2a730c5 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -19,13 +19,10 @@ "@listr2/prompt-adapter-inquirer": "4.2.4", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", - "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.54.0", - "ini": "7.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "14.0.0", - "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.4", "yargs": "18.0.0", diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 447782da0616..e10ff4b940b8 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -254,10 +254,16 @@ export default class UpdateCommandModule extends CommandModule string); +export class RegistryClient { + private metadataCache = new Map>(); + private manifestCache = new Map>(); + + constructor( + private packageManager: PackageManager, + private logger: logging.LoggerApi, + ) {} + + async getMetadata(packageName: string): Promise { + let promise = this.metadataCache.get(packageName); + if (!promise) { + promise = this.packageManager.getRegistryMetadata(packageName).catch((e) => { + this.metadataCache.delete(packageName); + throw e; + }); + this.metadataCache.set(packageName, promise); + } + + return promise; + } + + async getManifest(packageName: string, version: string): Promise { + const key = `${packageName}@${version}`; + let promise = this.manifestCache.get(key); + if (!promise) { + promise = this.packageManager.getRegistryManifest(packageName, version).catch((e) => { + this.manifestCache.delete(key); + throw e; + }); + this.manifestCache.set(key, promise); + } + + return promise; + } +} + +export async function getSatisfyingVersion( + registryClient: RegistryClient, + packageName: string, + versions: string[], + range: string, + next?: boolean, +): Promise { + const options = { includePrerelease: next || undefined }; + const candidates = versions.filter((v) => semver.satisfies(v, range, options)); + const sorted = semver.rsort(candidates); + + for (const version of sorted) { + const manifest = await registryClient.getManifest(packageName, version); + if (manifest && !manifest.deprecated) { + return version; + } + } + + // Fallback to deprecated versions if no non-deprecated version satisfies + for (const version of sorted) { + const manifest = await registryClient.getManifest(packageName, version); + if (manifest) { + return version; + } + } + + return null; +} + export function angularMajorCompatGuarantee(range: string) { let newRange = semver.validRange(range); if (!newRange) { @@ -54,7 +116,7 @@ export interface PackageVersionInfo { export interface PackageInfo { name: string; - npmPackageJson: NpmRepositoryPackageJson; + npmPackageJson: PackageMetadata; installed: PackageVersionInfo; target?: PackageVersionInfo; packageJsonRange: string; @@ -84,6 +146,7 @@ export interface UpdatePlan { packagesToUpdate: Map; // name -> target version range migrationsToRun: { package: string; collection: string; from: string; to: string }[]; packageInfoMap: Map; + registryClient: RegistryClient; } function _updatePeerVersion(infoMap: Map, name: string, range: string) { @@ -215,7 +278,7 @@ function _getUpdateMetadata( packageJson: PackageManifest, logger: logging.LoggerApi, ): UpdateMetadata { - const metadata = packageJson['ng-update']; + const metadata = packageJson['ng-update'] as Record | undefined; const result: UpdateMetadata = { packageGroup: {}, @@ -337,13 +400,11 @@ function _buildLocalPackageInfo( } const installedVersion = localPkgJson.version; - const npmPackageJson: NpmRepositoryPackageJson = { + const npmPackageJson: PackageMetadata = { name, - versions: { - [installedVersion]: localPkgJson, - }, + versions: [installedVersion], 'dist-tags': {}, - } as unknown as NpmRepositoryPackageJson; + }; const logger = new logging.NullLogger(); @@ -359,13 +420,14 @@ function _buildLocalPackageInfo( }; } -function _buildPackageInfo( +async function _buildPackageInfo( packages: Map, allDependencies: ReadonlyMap, - npmPackageJson: NpmRepositoryPackageJson, + npmPackageJson: PackageMetadata, workspaceRoot: string, + registryClient: RegistryClient, logger: logging.LoggerApi, -): PackageInfo { +): Promise { const name = npmPackageJson.name; const packageJsonRange = allDependencies.get(name); if (!packageJsonRange) { @@ -375,24 +437,13 @@ function _buildPackageInfo( const localPkgJson = getInstalledPackageJson(name, workspaceRoot); let installedVersion = localPkgJson?.version; - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - - for (const [version, { deprecated }] of Object.entries(npmPackageJson.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(version); - } else { - packageVersionsNonDeprecated.push(version); - } - } - - const findSatisfyingVersion = (targetVersion: VersionRange): VersionRange | undefined => - ((semver.maxSatisfying(packageVersionsNonDeprecated, targetVersion) ?? - semver.maxSatisfying(packageVersionsDeprecated, targetVersion)) as VersionRange | null) ?? - undefined; - if (!installedVersion) { - installedVersion = findSatisfyingVersion(packageJsonRange); + installedVersion = (await getSatisfyingVersion( + registryClient, + name, + npmPackageJson.versions, + packageJsonRange, + )) as VersionRange | undefined; } if (!installedVersion) { @@ -401,8 +452,8 @@ function _buildPackageInfo( ); } - const versions = npmPackageJson.versions ?? {}; - const installedPackageJson = versions[installedVersion] || localPkgJson; + const installedPackageJson = + localPkgJson || (await registryClient.getManifest(name, installedVersion)); if (!installedPackageJson) { throw new Error( `An unexpected error happened; package ${name} has no version ${installedVersion}.`, @@ -417,7 +468,12 @@ function _buildPackageInfo( } else if (targetVersion == 'next') { targetVersion = distTags['latest'] as VersionRange; } else { - targetVersion = findSatisfyingVersion(targetVersion); + targetVersion = (await getSatisfyingVersion( + registryClient, + name, + npmPackageJson.versions, + targetVersion, + )) as VersionRange | undefined; } } @@ -426,13 +482,17 @@ function _buildPackageInfo( targetVersion = undefined; } - const target: PackageVersionInfo | undefined = targetVersion - ? { + let target: PackageVersionInfo | undefined; + if (targetVersion) { + const targetPackageJson = await registryClient.getManifest(name, targetVersion); + if (targetPackageJson) { + target = { version: targetVersion, - packageJson: versions[targetVersion], - updateMetadata: _getUpdateMetadata(versions[targetVersion], logger), - } - : undefined; + packageJson: targetPackageJson, + updateMetadata: _getUpdateMetadata(targetPackageJson, logger), + }; + } + } return { name, @@ -487,11 +547,12 @@ function _buildPackageList( return packages; } -function resolvePackageVersion( - metadata: NpmRepositoryPackageJson, +async function resolvePackageVersion( + registryClient: RegistryClient, + metadata: PackageMetadata, range: string, next = false, -): string | null { +): Promise { const distTags = metadata['dist-tags'] ?? {}; if (distTags[range]) { return distTags[range]; @@ -500,32 +561,16 @@ function resolvePackageVersion( return distTags['latest'] ?? null; } - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - for (const [v, { deprecated }] of Object.entries(metadata.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } - - return ( - semver.maxSatisfying(packageVersionsNonDeprecated, range, { - includePrerelease: next || undefined, - }) ?? - semver.maxSatisfying(packageVersionsDeprecated, range, { - includePrerelease: next || undefined, - }) - ); + return getSatisfyingVersion(registryClient, metadata.name, metadata.versions, range, next); } -function _addPackageGroup( +async function _addPackageGroup( packages: Map, allDependencies: ReadonlyMap, - metadata: NpmRepositoryPackageJson, + metadata: PackageMetadata, + registryClient: RegistryClient, logger: logging.LoggerApi, -): void { +): Promise { const maybePackage = packages.get(metadata.name); if (!maybePackage) { return; @@ -538,27 +583,20 @@ function _addPackageGroup( } else if (version === 'next') { version = distTags['latest'] as VersionRange; } else { - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - const versions = metadata.versions ?? {}; - for (const [v, { deprecated }] of Object.entries(versions)) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } version = - ((semver.maxSatisfying(packageVersionsNonDeprecated, version) ?? - semver.maxSatisfying(packageVersionsDeprecated, version)) as VersionRange | null) ?? - version; + ((await getSatisfyingVersion( + registryClient, + metadata.name, + metadata.versions, + version, + )) as VersionRange | null) ?? version; } - const versions = metadata.versions ?? {}; - if (!versions[version]) { + const packageJson = await registryClient.getManifest(metadata.name, version); + if (!packageJson) { return; } - const ngUpdateMetadata = versions[version]['ng-update']; + const ngUpdateMetadata = packageJson['ng-update']; if (!ngUpdateMetadata) { return; } @@ -607,9 +645,9 @@ function _addPackageGroup( async function _addPeerDependencies( packages: Map, allDependencies: ReadonlyMap, - npmPackageJson: NpmRepositoryPackageJson, + npmPackageJson: PackageMetadata, workspaceRoot: string, - fetchMetadata: (name: string) => Promise, + registryClient: RegistryClient, logger: logging.LoggerApi, ): Promise { const maybePackage = packages.get(npmPackageJson.name); @@ -619,8 +657,7 @@ async function _addPeerDependencies( const distTags = npmPackageJson['dist-tags'] ?? {}; const version = distTags[maybePackage] || maybePackage; - const versions = npmPackageJson.versions ?? {}; - const packageJson = versions[version]; + const packageJson = await registryClient.getManifest(npmPackageJson.name, version); if (!packageJson) { return; } @@ -638,20 +675,14 @@ async function _addPeerDependencies( } else { const packageJsonRange = allDependencies.get(peer); if (packageJsonRange) { - const peerMetadata = await fetchMetadata(peer); + const peerMetadata = await registryClient.getMetadata(peer); if (peerMetadata) { - const packageVersionsNonDeprecated: string[] = []; - const packageVersionsDeprecated: string[] = []; - for (const [v, { deprecated }] of Object.entries(peerMetadata.versions ?? {})) { - if (deprecated) { - packageVersionsDeprecated.push(v); - } else { - packageVersionsNonDeprecated.push(v); - } - } - const resolvedInstalledVersion = - semver.maxSatisfying(packageVersionsNonDeprecated, packageJsonRange) ?? - semver.maxSatisfying(packageVersionsDeprecated, packageJsonRange); + const resolvedInstalledVersion = await getSatisfyingVersion( + registryClient, + peer, + peerMetadata.versions, + packageJsonRange, + ); if (resolvedInstalledVersion && semver.satisfies(resolvedInstalledVersion, range)) { continue; @@ -684,6 +715,7 @@ function isPkgFromRegistry(name: string, specifier: string): boolean { export async function resolveUserUpdatePlan( options: UpdateResolverOptions, + packageManager: PackageManager, logger: logging.LoggerApi, ): Promise { const workspaceRoot = options.workspaceRoot ?? process.cwd(); @@ -733,25 +765,12 @@ export async function resolveUserUpdatePlan( const usingYarn = options.packageManager === 'yarn'; const packages = _buildPackageList(options, npmDeps, logger); - const npmPackageJsonMap = new Map(); + const registryClient = new RegistryClient(packageManager, logger); const getOrFetchPackageMetadata = async ( packageName: string, - ): Promise => { - let metadata = npmPackageJsonMap.get(packageName); - if (!metadata) { - const raw = await getNpmPackageJson(packageName, logger, { - registry: options.registry, - usingYarn, - verbose: options.verbose, - }); - if (raw.name) { - metadata = raw as NpmRepositoryPackageJson; - npmPackageJsonMap.set(packageName, metadata); - } - } - - return metadata ?? null; + ): Promise => { + return registryClient.getMetadata(packageName); }; if (packages.size === 0) { @@ -772,11 +791,16 @@ export async function resolveUserUpdatePlan( const metadata = await getOrFetchPackageMetadata(name); const spec = packages.get(name); if (metadata && spec) { - const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); + const resolvedVersion = await resolvePackageVersion( + registryClient, + metadata, + spec, + !!options.next, + ); if (resolvedVersion) { packages.set(name, resolvedVersion as VersionRange); } - _addPackageGroup(packages, npmDeps, metadata, logger); + await _addPackageGroup(packages, npmDeps, metadata, registryClient, logger); } } } while (packages.size > lastGroupSize); @@ -785,7 +809,12 @@ export async function resolveUserUpdatePlan( const metadata = await getOrFetchPackageMetadata(name); const spec = packages.get(name); if (metadata && spec) { - const resolvedVersion = resolvePackageVersion(metadata, spec, !!options.next); + const resolvedVersion = await resolvePackageVersion( + registryClient, + metadata, + spec, + !!options.next, + ); if (resolvedVersion) { packages.set(name, resolvedVersion as VersionRange); } @@ -794,7 +823,7 @@ export async function resolveUserUpdatePlan( npmDeps, metadata, workspaceRoot, - getOrFetchPackageMetadata, + registryClient, logger, ); } @@ -802,25 +831,31 @@ export async function resolveUserUpdatePlan( } while (packages.size > lastPackagesSize); } - const packageInfoMap = new Map(); - for (const depName of npmDeps.keys()) { - const isUpdating = packages.has(depName); - const localPkgJson = getInstalledPackageJson(depName, workspaceRoot); - - if (isUpdating || !localPkgJson) { - const metadata = await getOrFetchPackageMetadata(depName); - if (metadata) { - packageInfoMap.set( - depName, - _buildPackageInfo(packages, npmDeps, metadata, workspaceRoot, logger), - ); - } else { - packageInfoMap.set(depName, _buildLocalPackageInfo(depName, npmDeps, workspaceRoot)); + const packageInfoEntries = await Promise.all( + Array.from(npmDeps.keys(), async (depName) => { + const isUpdating = packages.has(depName); + const localPkgJson = getInstalledPackageJson(depName, workspaceRoot); + + if (isUpdating || !localPkgJson) { + const metadata = await getOrFetchPackageMetadata(depName); + if (metadata) { + const info = await _buildPackageInfo( + packages, + npmDeps, + metadata, + workspaceRoot, + registryClient, + logger, + ); + + return [depName, info] as const; + } } - } else { - packageInfoMap.set(depName, _buildLocalPackageInfo(depName, npmDeps, workspaceRoot)); - } - } + + return [depName, _buildLocalPackageInfo(depName, npmDeps, workspaceRoot)] as const; + }), + ); + const packageInfoMap = new Map(packageInfoEntries); const packagesToUpdate = new Map(); const migrationsToRun: { package: string; collection: string; from: string; to: string }[] = []; @@ -852,22 +887,23 @@ export async function resolveUserUpdatePlan( packagesToUpdate, migrationsToRun, packageInfoMap, + registryClient, }; } -export function printUpdateUsageMessage( +export async function printUpdateUsageMessage( infoMap: Map, + registryClient: RegistryClient, logger: logging.LoggerApi, next = false, -) { +): Promise { const packageGroups = new Map(); - const packagesToUpdate = [...infoMap.entries()] - .map(([name, info]) => { + const mappedPackages = await Promise.all( + Array.from(infoMap.entries(), async ([name, info]) => { const distTags = info.npmPackageJson['dist-tags'] ?? {}; let tag = next ? (distTags['next'] ? 'next' : 'latest') : 'latest'; let version = distTags[tag] ?? info.installed.version; - const versions = info.npmPackageJson.versions ?? {}; - let target = versions[version]; + const versions = info.npmPackageJson.versions ?? []; const versionDiff = semver.diff(info.installed.version, version); if ( @@ -883,18 +919,19 @@ export function printUpdateUsageMessage( installedMajorVersion < toInstallMajorVersion - 1 ) { const nextMajorVersion = `${installedMajorVersion + 1}.`; - const nextMajorVersions = Object.keys(versions) + const nextMajorVersions = versions .filter((v) => v.startsWith(nextMajorVersion)) .sort((a, b) => (a > b ? -1 : 1)); if (nextMajorVersions.length) { version = nextMajorVersions[0]; - target = versions[version]; tag = ''; } } } + const target = info.target?.packageJson || (await registryClient.getManifest(name, version)); + return { name, info, @@ -902,21 +939,25 @@ export function printUpdateUsageMessage( tag, target, }; - }) + }), + ); + + const packagesToUpdate = mappedPackages .filter( ({ info, version, target }) => target?.['ng-update'] && semver.compare(info.installed.version, version) < 0, ) .map(({ name, info, version, tag, target }) => { // Look for packageGroup. - const ngUpdate = target['ng-update']; + const ngUpdate = target?.['ng-update'] as Record | undefined; const packageGroup = ngUpdate?.['packageGroup']; if (packageGroup) { const packageGroupNames = Array.isArray(packageGroup) ? packageGroup : Object.keys(packageGroup); const packageGroupName = - ngUpdate?.['packageGroupName'] || packageGroupNames.find((n) => infoMap.has(n)); + (ngUpdate?.['packageGroupName'] as string | undefined) || + packageGroupNames.find((n) => infoMap.has(n)); if (packageGroupName) { if (packageGroups.has(name)) { diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts index 6953b9817906..ae410765bd4b 100644 --- a/packages/angular/cli/src/commands/update/update-resolver_spec.ts +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -7,11 +7,14 @@ */ import { logging } from '@angular-devkit/core'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; +import type { PackageManager, PackageManifest } from '../../package-managers'; import { + RegistryClient, + UpdateResolverOptions, angularMajorCompatGuarantee, applyUpdatePlan, resolveUserUpdatePlan, @@ -47,6 +50,127 @@ describe('UpdateResolver', () => { rmSync(tempRoot, { recursive: true, force: true }); }); + const MOCK_REGISTRY: Record< + string, + { + metadata: { name: string; 'dist-tags': Record; versions: string[] }; + manifests: Record; + } + > = { + '@angular-devkit-tests/update-base': { + metadata: { + name: '@angular-devkit-tests/update-base', + 'dist-tags': { latest: '1.1.0' }, + versions: ['1.0.0', '1.1.0'], + }, + manifests: { + '1.0.0': { name: '@angular-devkit-tests/update-base', version: '1.0.0' }, + '1.1.0': { name: '@angular-devkit-tests/update-base', version: '1.1.0' }, + }, + }, + '@angular-devkit-tests/update-peer-dependencies-angular-5': { + metadata: { + name: '@angular-devkit-tests/update-peer-dependencies-angular-5', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + }, + manifests: { + '1.0.0': { + name: '@angular-devkit-tests/update-peer-dependencies-angular-5', + version: '1.0.0', + peerDependencies: { + '@angular/core': '^5.0.0', + }, + }, + }, + }, + '@angular-devkit-tests/update-package-group-1': { + metadata: { + name: '@angular-devkit-tests/update-package-group-1', + 'dist-tags': { latest: '1.2.0' }, + versions: ['1.0.0', '1.2.0'], + }, + manifests: { + '1.0.0': { name: '@angular-devkit-tests/update-package-group-1', version: '1.0.0' }, + '1.2.0': { + name: '@angular-devkit-tests/update-package-group-1', + version: '1.2.0', + 'ng-update': { + packageGroup: { + '@angular-devkit-tests/update-package-group-1': '', + '@angular-devkit-tests/update-package-group-2': '^2', + }, + }, + }, + }, + }, + '@angular-devkit-tests/update-package-group-2': { + metadata: { + name: '@angular-devkit-tests/update-package-group-2', + 'dist-tags': { latest: '2.0.0' }, + versions: ['1.0.0', '2.0.0'], + }, + manifests: { + '1.0.0': { name: '@angular-devkit-tests/update-package-group-2', version: '1.0.0' }, + '2.0.0': { + name: '@angular-devkit-tests/update-package-group-2', + version: '2.0.0', + 'ng-update': { + packageGroup: { + '@angular-devkit-tests/update-package-group-1': '^1', + '@angular-devkit-tests/update-package-group-2': '', + }, + }, + }, + }, + }, + '@angular/core': { + metadata: { + name: '@angular/core', + 'dist-tags': { latest: '6.0.0' }, + versions: ['5.1.0', '6.0.0'], + }, + manifests: { + '5.1.0': { name: '@angular/core', version: '5.1.0' }, + '6.0.0': { name: '@angular/core', version: '6.0.0' }, + }, + }, + 'rxjs': { + metadata: { + name: 'rxjs', + 'dist-tags': { latest: '5.5.0' }, + versions: ['5.5.0'], + }, + manifests: { + '5.5.0': { name: 'rxjs', version: '5.5.0' }, + }, + }, + 'zone.js': { + metadata: { + name: 'zone.js', + 'dist-tags': { latest: '0.8.26' }, + versions: ['0.8.26'], + }, + manifests: { + '0.8.26': { name: 'zone.js', version: '0.8.26' }, + }, + }, + }; + + async function resolvePlan(options: UpdateResolverOptions) { + const mockPackageManager = { + name: 'npm', + async getRegistryMetadata(packageName: string) { + return MOCK_REGISTRY[packageName]?.metadata ?? null; + }, + async getRegistryManifest(packageName: string, version: string) { + return MOCK_REGISTRY[packageName]?.manifests[version] ?? null; + }, + } as unknown as PackageManager; + + return resolveUserUpdatePlan(options, mockPackageManager, logger); + } + function createMockWorkspace( packageJson: Record, nodeModules: { [name: string]: { version: string; manifest?: Record } } = {}, @@ -70,13 +194,10 @@ describe('UpdateResolver', () => { }, }); - const plan = await resolveUserUpdatePlan( - { - packages: [], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: [], + workspaceRoot: tempRoot, + }); expect(plan.packagesToUpdate.size).toBe(0); }); @@ -95,13 +216,10 @@ describe('UpdateResolver', () => { }, ); - const plan = await resolveUserUpdatePlan( - { - packages: ['@angular-devkit-tests/update-base'], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }); expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-base')).toBe('1.1.0'); }); @@ -125,13 +243,10 @@ describe('UpdateResolver', () => { }, ); - const plan = await resolveUserUpdatePlan( - { - packages: ['@angular/core@^6.0.0'], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: ['@angular/core@^6.0.0'], + workspaceRoot: tempRoot, + }); expect(plan.packagesToUpdate.get('@angular/core')?.[0]).toBe('6'); }); @@ -151,13 +266,10 @@ describe('UpdateResolver', () => { }, ); - const plan = await resolveUserUpdatePlan( - { - packages: ['@angular-devkit-tests/update-package-group-1'], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: ['@angular-devkit-tests/update-package-group-1'], + workspaceRoot: tempRoot, + }); expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-package-group-1')).toBe('1.2.0'); expect(plan.packagesToUpdate.get('@angular-devkit-tests/update-package-group-2')).toBe('2.0.0'); @@ -182,13 +294,10 @@ describe('UpdateResolver', () => { JSON.stringify({ name: '@angular-devkit-tests/update-base', version: '1.0.0' }, null, 2), ); - const plan = await resolveUserUpdatePlan( - { - packages: ['@angular-devkit-tests/update-base'], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }); await applyUpdatePlan(tempRoot, plan, logger); @@ -214,13 +323,10 @@ describe('UpdateResolver', () => { JSON.stringify({ name: '@angular-devkit-tests/update-base', version: '1.0.0' }, null, 2), ); - const plan = await resolveUserUpdatePlan( - { - packages: ['@angular-devkit-tests/update-base'], - workspaceRoot: tempRoot, - }, - logger, - ); + const plan = await resolvePlan({ + packages: ['@angular-devkit-tests/update-base'], + workspaceRoot: tempRoot, + }); await applyUpdatePlan(tempRoot, plan, logger); @@ -228,3 +334,91 @@ describe('UpdateResolver', () => { expect(result.endsWith('}')).toBeTrue(); }); }); + +describe('RegistryClient', () => { + const logger = new logging.NullLogger(); + + it('should cache metadata requests', async () => { + let callCount = 0; + const mockPackageManager = { + async getRegistryMetadata() { + callCount++; + + return { name: 'test-pkg', 'dist-tags': {}, versions: [] }; + }, + } as unknown as PackageManager; + + const client = new RegistryClient(mockPackageManager, logger); + const m1 = await client.getMetadata('test-pkg'); + const m2 = await client.getMetadata('test-pkg'); + + expect(callCount).toBe(1); + expect(m1).toEqual(m2); + }); + + it('should evict metadata requests from cache upon failure', async () => { + let callCount = 0; + const mockPackageManager = { + async getRegistryMetadata() { + callCount++; + if (callCount === 1) { + throw new Error('Transient error'); + } + + return { name: 'test-pkg', 'dist-tags': {}, versions: [] }; + }, + } as unknown as PackageManager; + + const client = new RegistryClient(mockPackageManager, logger); + + await expectAsync(client.getMetadata('test-pkg')).toBeRejectedWithError('Transient error'); + const m2 = await client.getMetadata('test-pkg'); + + expect(callCount).toBe(2); + expect(m2).not.toBeNull(); + expect(m2?.name).toBe('test-pkg'); + }); + + it('should cache manifest requests', async () => { + let callCount = 0; + const mockPackageManager = { + async getRegistryManifest() { + callCount++; + + return { name: 'test-pkg', version: '1.0.0' }; + }, + } as unknown as PackageManager; + + const client = new RegistryClient(mockPackageManager, logger); + const m1 = await client.getManifest('test-pkg', '1.0.0'); + const m2 = await client.getManifest('test-pkg', '1.0.0'); + + expect(callCount).toBe(1); + expect(m1).toEqual(m2); + }); + + it('should evict manifest requests from cache upon failure', async () => { + let callCount = 0; + const mockPackageManager = { + async getRegistryManifest() { + callCount++; + if (callCount === 1) { + throw new Error('Transient error'); + } + + return { name: 'test-pkg', version: '1.0.0' }; + }, + } as unknown as PackageManager; + + const client = new RegistryClient(mockPackageManager, logger); + + await expectAsync(client.getManifest('test-pkg', '1.0.0')).toBeRejectedWithError( + 'Transient error', + ); + const m2 = await client.getManifest('test-pkg', '1.0.0'); + + expect(callCount).toBe(2); + expect(m2).not.toBeNull(); + expect(m2?.version).toBe('1.0.0'); + }); +}); diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index cee68015f677..90f426f9ab71 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -129,18 +129,33 @@ export const NodeJS_HOST: Host = { const isWin32 = platform() === 'win32'; return new Promise((resolve, reject) => { + const env: Record = { + ...process.env, + ...options.env, + // NPM updater notifier will prevents the child process from closing until it timeout after 3 minutes. + NO_UPDATE_NOTIFIER: '1', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }; + + // When running via Yarn Classic (`yarn run