From dcef1d2b8369a42c636fc93ddb9bb32eb7fcb3b7 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:49:36 +0000 Subject: [PATCH 1/2] feat(rush): preserve existing autoinstaller lockfiles on request Add an opt-in PNPM reconciliation mode that keeps the existing wanted and internal lockfiles and uses a non-frozen install. Preserve the default full-update behavior and reject unsupported managers or missing locks. Cover CLI forwarding, failure propagation, and actual-manager retention and fresh-install behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...toinstaller-lockfile_2026-10-12-12-45.json | 10 + libraries/rush-lib/README.md | 22 + .../cli/actions/UpdateAutoinstallerAction.ts | 18 +- .../src/cli/test/Autoinstaller.test.ts | 116 ++++- libraries/rush-lib/src/logic/Autoinstaller.ts | 50 ++- .../src/logic/test/Autoinstaller.pnpm.test.ts | 417 ++++++++++++++++++ 6 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 common/changes/@microsoft/rush/preserve-autoinstaller-lockfile_2026-10-12-12-45.json create mode 100644 libraries/rush-lib/src/logic/test/Autoinstaller.pnpm.test.ts diff --git a/common/changes/@microsoft/rush/preserve-autoinstaller-lockfile_2026-10-12-12-45.json b/common/changes/@microsoft/rush/preserve-autoinstaller-lockfile_2026-10-12-12-45.json new file mode 100644 index 00000000000..04f7ada42f2 --- /dev/null +++ b/common/changes/@microsoft/rush/preserve-autoinstaller-lockfile_2026-10-12-12-45.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add an opt-in --preserve-lockfile flag to rush update-autoinstaller for reconciling package.json with an existing PNPM lockfile. The default full-update behavior is unchanged.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/README.md b/libraries/rush-lib/README.md index 291d996cdbc..92ec49b02c5 100644 --- a/libraries/rush-lib/README.md +++ b/libraries/rush-lib/README.md @@ -15,6 +15,28 @@ to the **@microsoft/rush-lib** version number that it depends on. API documentation for this package: https://rushjs.io/pages/advanced/api/ +## Reconciling an autoinstaller manifest + +`rush update-autoinstaller --name tools` deletes the existing lockfile and performs +a full update, as before. For PNPM, opt into using the existing lockfile instead: + +```sh +rush update-autoinstaller --name tools --preserve-lockfile +``` + +This requires an existing lockfile and runs `pnpm install --no-frozen-lockfile`, +allowing manifest changes to be resolved even in CI. It does not freeze dependency +versions: PNPM can still change resolutions, including transitive dependencies, +when reconciling the dependency graph. Review the resulting lockfile diff. +The option does not change lifecycle-script, hook, or registry configuration. +NPM and Yarn are not supported by this option. + +The `Autoinstaller.pnpm.test.ts` Heft test uses a loopback registry and the +repository's installed PNPM. Set `PNPM_TEST_EXECUTABLE` to another PNPM executable +to exercise the same fixture against other versions without changing `rush.json`. +The fixture supplies an isolated child-process environment and project-local +configuration and caches; CI package-manager settings are not inherited. + ## Links - [Experimental environment variables](../../docs/rush/environment-variables.md) - Daemon opt-in, diff --git a/libraries/rush-lib/src/cli/actions/UpdateAutoinstallerAction.ts b/libraries/rush-lib/src/cli/actions/UpdateAutoinstallerAction.ts index e8e2f7085b2..a80a628a4eb 100644 --- a/libraries/rush-lib/src/cli/actions/UpdateAutoinstallerAction.ts +++ b/libraries/rush-lib/src/cli/actions/UpdateAutoinstallerAction.ts @@ -1,24 +1,38 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { CommandLineFlagParameter } from '@rushstack/ts-command-line'; + import type { RushCommandLineParser } from '../RushCommandLineParser'; import type { Autoinstaller } from '../../logic/Autoinstaller'; import { BaseAutoinstallerAction } from './BaseAutoinstallerAction'; export class UpdateAutoinstallerAction extends BaseAutoinstallerAction { + readonly #preserveLockfile: CommandLineFlagParameter; + public constructor(parser: RushCommandLineParser) { super({ actionName: 'update-autoinstaller', summary: 'Updates autoinstaller package dependencies', - documentation: 'Use this command to regenerate the shrinkwrap file for an autoinstaller folder.', + documentation: + 'Use this command to regenerate the shrinkwrap file for an autoinstaller folder.' + + ' By default, the existing lockfile is deleted to perform a full update.', parser }); + + this.#preserveLockfile = this.defineFlagParameter({ + parameterLongName: '--preserve-lockfile', + description: + 'Reconcile package.json using the existing lockfile instead of deleting it for a full update.' + + ' Only supported for PNPM and requires an existing lockfile. Runs a non-frozen install;' + + ' this is not a guarantee that all existing resolutions remain unchanged. Review the lockfile diff.' + }); } protected async prepareAsync(autoinstaller: Autoinstaller): Promise { // Do not run `autoinstaller.prepareAsync` here. It tries to install the autoinstaller with // --frozen-lockfile or equivalent, which will fail if the autoinstaller's dependencies // have been changed. - await autoinstaller.updateAsync(); + await autoinstaller.updateAsync({ preserveLockfile: this.#preserveLockfile.value }); } } diff --git a/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts b/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts index f975427f50a..73ea29c0dbe 100644 --- a/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts +++ b/libraries/rush-lib/src/cli/test/Autoinstaller.test.ts @@ -3,9 +3,9 @@ import './mockRushCommandLineParser'; -import { FileSystem } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; -import { Autoinstaller } from '../../logic/Autoinstaller'; +import { Autoinstaller, type IAutoinstallerUpdateOptions } from '../../logic/Autoinstaller'; import { InstallHelpers } from '../../logic/installManager/InstallHelpers'; import { RushConstants } from '../../logic/RushConstants'; import { Utilities } from '../../utilities/Utilities'; @@ -102,4 +102,116 @@ describe(Autoinstaller.name, () => { ); } }); + + describe('updateAsync', () => { + async function createAutoinstallerAsync(): Promise { + const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller'); + const autoinstaller: Autoinstaller = new Autoinstaller({ + autoinstallerName: 'tools', + rushConfiguration: parser.rushConfiguration, + rushGlobalFolder: parser.rushGlobalFolder, + restrictConsoleOutput: true + }); + await JsonFile.saveAsync( + { name: 'tools', version: '1.0.0', dependencies: { 'example-tool': '1.0.0' } }, + autoinstaller.packageJsonPath, + { ensureFolderExists: true } + ); + jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined); + jest.spyOn(Utilities, 'syncNpmrc').mockImplementation(() => undefined); + return autoinstaller; + } + + it.each([undefined, { preserveLockfile: false }, { preserveLockfile: true }])( + 'only retains both PNPM lockfiles when explicitly requested: %j', + async (options: IAutoinstallerUpdateOptions | undefined) => { + const autoinstaller: Autoinstaller = await createAutoinstallerAsync(); + const internalLockfilePath: string = `${autoinstaller.folderFullPath}/node_modules/.pnpm/lock.yaml`; + await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'old lockfile\r\n'); + await FileSystem.writeFileAsync(internalLockfilePath, 'old internal lockfile\r\n', { + ensureFolderExists: true + }); + + const executeSpy = jest.spyOn(Utilities, 'executeCommandAsync').mockImplementation(async (command) => { + if (options?.preserveLockfile) { + expect(await FileSystem.readFileAsync(autoinstaller.shrinkwrapFilePath)).toBe('old lockfile\r\n'); + expect(await FileSystem.readFileAsync(internalLockfilePath)).toBe('old internal lockfile\r\n'); + } else { + expect(await FileSystem.existsAsync(autoinstaller.shrinkwrapFilePath)).toBe(false); + expect(await FileSystem.existsAsync(internalLockfilePath)).toBe(false); + } + expect(command).toEqual( + expect.objectContaining({ + args: options?.preserveLockfile ? ['install', '--no-frozen-lockfile'] : ['install'], + workingDirectory: autoinstaller.folderFullPath, + keepEnvironment: true, + useShell: true + }) + ); + await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'new lockfile\n'); + }); + + await autoinstaller.updateAsync(options); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(Utilities.syncNpmrc).toHaveBeenCalledWith( + expect.objectContaining({ + targetNpmrcFolder: autoinstaller.folderFullPath, + supportEnvVarFallbackSyntax: true + }) + ); + expect( + await FileSystem.existsAsync(`${autoinstaller.folderFullPath}/node_modules/rush-autoinstaller.flag`) + ).toBe(false); + } + ); + + it('requires an existing lockfile instead of silently performing a full update', async () => { + const autoinstaller: Autoinstaller = await createAutoinstallerAsync(); + const executeSpy = jest.spyOn(Utilities, 'executeCommandAsync'); + + await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow( + 'requires an existing lockfile' + ); + expect(executeSpy).not.toHaveBeenCalled(); + expect(Utilities.syncNpmrc).not.toHaveBeenCalled(); + }); + + it('propagates installation failures without deleting the existing lockfile', async () => { + const autoinstaller: Autoinstaller = await createAutoinstallerAsync(); + await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'old lockfile\n'); + jest.spyOn(Utilities, 'executeCommandAsync').mockRejectedValue(new Error('install failed')); + + await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow('install failed'); + expect(await FileSystem.readFileAsync(autoinstaller.shrinkwrapFilePath)).toBe('old lockfile\n'); + }); + + it.each(['npm', 'yarn'])('rejects preservation with %s before installing anything', async (packageManager) => { + const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller'); + Object.assign(parser.rushConfiguration, { isPnpm: false, packageManager }); + const ensureSpy = jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync'); + const autoinstaller: Autoinstaller = new Autoinstaller({ + autoinstallerName: 'tools', + rushConfiguration: parser.rushConfiguration, + rushGlobalFolder: parser.rushGlobalFolder + }); + + await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow( + 'only supported when using PNPM' + ); + expect(ensureSpy).not.toHaveBeenCalled(); + }); + }); + + it.each([false, true])('passes the CLI preserve-lockfile option through: %s', async (preserveLockfile) => { + const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller'); + process.argv.push('--name', 'tools', ...(preserveLockfile ? ['--preserve-lockfile'] : [])); + const updateSpy = jest.spyOn(Autoinstaller.prototype, 'updateAsync').mockResolvedValue(undefined); + const prepareSpy = jest.spyOn(Autoinstaller.prototype, 'prepareAsync').mockResolvedValue(undefined); + + await expect(parser.executeAsync()).resolves.toBe(true); + + expect(updateSpy).toHaveBeenCalledWith({ preserveLockfile }); + expect(prepareSpy).not.toHaveBeenCalled(); + }); }); diff --git a/libraries/rush-lib/src/logic/Autoinstaller.ts b/libraries/rush-lib/src/logic/Autoinstaller.ts index 6db745b78c8..3fc04c0924c 100644 --- a/libraries/rush-lib/src/logic/Autoinstaller.ts +++ b/libraries/rush-lib/src/logic/Autoinstaller.ts @@ -33,6 +33,15 @@ export interface IAutoinstallerOptions { restrictConsoleOutput?: boolean; } +export interface IAutoinstallerUpdateOptions { + /** + * Reconcile package.json using the existing PNPM lockfile instead of deleting it for a full update. + * Requires an existing lockfile. PNPM may still change resolutions as needed; review the resulting diff. + * The default is false. + */ + preserveLockfile?: boolean; +} + export class Autoinstaller { public readonly name: string; @@ -181,7 +190,12 @@ export class Autoinstaller { } } - public async updateAsync(): Promise { + public async updateAsync(options: IAutoinstallerUpdateOptions = {}): Promise { + const preserveLockfile: boolean = options.preserveLockfile ?? false; + if (preserveLockfile && !this.#rushConfiguration.isPnpm) { + throw new Error('The --preserve-lockfile option is only supported when using PNPM.'); + } + await InstallHelpers.ensureLocalPackageManagerAsync( this.#rushConfiguration, this.#rushGlobalFolder, @@ -203,21 +217,25 @@ export class Autoinstaller { if (await FileSystem.existsAsync(this.shrinkwrapFilePath)) { oldFileContents = FileSystem.readFile(this.shrinkwrapFilePath, { convertLineEndings: NewlineKind.Lf }); - this.#logIfConsoleOutputIsNotRestricted('Deleting ' + this.shrinkwrapFilePath); - await FileSystem.deleteFileAsync(this.shrinkwrapFilePath); - if (this.#rushConfiguration.isPnpm) { - // Workaround for https://github.com/pnpm/pnpm/issues/1890 - // - // When "rush update-autoinstaller" is run, Rush deletes "common/autoinstallers/my-task/pnpm-lock.yaml" - // so that a new lockfile will be generated. However "pnpm install" by design will try to recover - // "pnpm-lock.yaml" from "my-task/node_modules/.pnpm/lock.yaml", which may prevent a full upgrade. - // Deleting both files ensures that a new lockfile will always be generated. - const pnpmPackageManager: PnpmPackageManager = this.#rushConfiguration - .packageManagerWrapper as PnpmPackageManager; - await FileSystem.deleteFileAsync( - path.join(this.folderFullPath, pnpmPackageManager.internalShrinkwrapRelativePath) - ); + if (!preserveLockfile) { + this.#logIfConsoleOutputIsNotRestricted('Deleting ' + this.shrinkwrapFilePath); + await FileSystem.deleteFileAsync(this.shrinkwrapFilePath); + if (this.#rushConfiguration.isPnpm) { + // Workaround for https://github.com/pnpm/pnpm/issues/1890 + // + // When "rush update-autoinstaller" is run, Rush deletes "common/autoinstallers/my-task/pnpm-lock.yaml" + // so that a new lockfile will be generated. However "pnpm install" by design will try to recover + // "pnpm-lock.yaml" from "my-task/node_modules/.pnpm/lock.yaml", which may prevent a full upgrade. + // Deleting both files ensures that a new lockfile will always be generated. + const pnpmPackageManager: PnpmPackageManager = this.#rushConfiguration + .packageManagerWrapper as PnpmPackageManager; + await FileSystem.deleteFileAsync( + path.join(this.folderFullPath, pnpmPackageManager.internalShrinkwrapRelativePath) + ); + } } + } else if (preserveLockfile) { + throw new Error('The --preserve-lockfile option requires an existing lockfile: ' + this.shrinkwrapFilePath); } // Detect a common mistake where PNPM prints "Already up-to-date" without creating a shrinkwrap file @@ -243,7 +261,7 @@ export class Autoinstaller { await Utilities.executeCommandAsync({ command: this.#rushConfiguration.packageManagerToolFilename, - args: ['install'], + args: preserveLockfile ? ['install', '--no-frozen-lockfile'] : ['install'], workingDirectory: this.folderFullPath, environment: this.#getPackageManagerEnvironment(this.folderFullPath), keepEnvironment: true, diff --git a/libraries/rush-lib/src/logic/test/Autoinstaller.pnpm.test.ts b/libraries/rush-lib/src/logic/test/Autoinstaller.pnpm.test.ts new file mode 100644 index 00000000000..7110f1ce54f --- /dev/null +++ b/libraries/rush-lib/src/logic/test/Autoinstaller.pnpm.test.ts @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import { createRequire } from 'node:module'; + +import { FileSystem, JsonFile, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import * as yaml from 'js-yaml'; +import * as tar from 'tar'; + +import { Autoinstaller } from '../Autoinstaller'; +import { InstallHelpers } from '../installManager/InstallHelpers'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import { Utilities } from '../../utilities/Utilities'; + +interface ILockfile { + packages: Record }>; + snapshots?: Record }>; + dependencies?: Record; + importers?: Record }>; +} + +interface IRegistryPackage { + manifest: IPackageJson; + tarball: Buffer; + integrity: string; +} + +interface IPnpmFixturePackageJson extends IPackageJson { + pnpm: { + overrides: Record; + }; +} + +const PROJECT_FOLDER: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; +const TEST_FOLDER: string = path.join(PROJECT_FOLDER, 'temp/test/autoinstaller-pnpm'); +const LIFECYCLE_SCRIPT: string = + "node -e \"require('fs').writeFileSync(process.env.AUTOINSTALLER_LIFECYCLE_SENTINEL, 'ran')\""; + +describe('Autoinstaller with real PNPM', () => { + it('reconciles an existing lockfile without opting into a full update', async () => { + // By default, use the tool installed by Rush. An explicit path also permits testing older + // PNPM releases and native executables without changing this repository's package-manager pin. + const pnpmPath: string = + process.env.PNPM_TEST_EXECUTABLE ?? + path.resolve(PROJECT_FOLDER, '../../common/temp/pnpm-local/node_modules/.bin/pnpm'); + const originalEnvironment: NodeJS.ProcessEnv = process.env; + let server: Server | undefined; + let newerVersionsAvailable: boolean = false; + let tarballRequests: number = 0; + const unexpectedRequests: string[] = []; + const metadataRequests: Set = new Set(); + const requestedTarballs: string[] = []; + + await FileSystem.ensureEmptyFolderAsync(TEST_FOLDER); + await FileSystem.ensureFolderAsync(`${TEST_FOLDER}/home`); + await FileSystem.writeFileAsync(`${TEST_FOLDER}/empty.npmrc`, ''); + await FileSystem.writeFileAsync(`${TEST_FOLDER}/empty-global.npmrc`, ''); + const fixtureEnvironment: NodeJS.ProcessEnv = { + CI: 'true', + HOME: `${TEST_FOLDER}/home`, + USERPROFILE: `${TEST_FOLDER}/home`, + APPDATA: `${TEST_FOLDER}/home/appdata`, + LOCALAPPDATA: `${TEST_FOLDER}/home/localappdata`, + XDG_CONFIG_HOME: `${TEST_FOLDER}/home/config`, + XDG_CACHE_HOME: `${TEST_FOLDER}/home/cache`, + XDG_DATA_HOME: `${TEST_FOLDER}/home/data`, + XDG_STATE_HOME: `${TEST_FOLDER}/home/state`, + TMPDIR: TEST_FOLDER, + TMP: TEST_FOLDER, + TEMP: TEST_FOLDER, + AUTOINSTALLER_LIFECYCLE_SENTINEL: `${TEST_FOLDER}/lifecycle-ran` + }; + // Inherit only executable lookup and OS variables, not CI registry settings, proxies or credentials. + const inheritedVariableNames: Set = new Set([ + 'PATH', + 'PATHEXT', + 'SYSTEMROOT', + 'SYSTEMDRIVE', + 'WINDIR', + 'COMSPEC' + ]); + for (const name of Object.keys(originalEnvironment)) { + const normalizedName: string = name.toUpperCase(); + if (inheritedVariableNames.has(normalizedName)) { + fixtureEnvironment[normalizedName] = originalEnvironment[name]; + } + } + function setPackageManagerSetting(name: string, value: string): void { + for (const prefix of ['npm_config_', 'pnpm_config_']) { + fixtureEnvironment[`${prefix}${name}`] = value; + } + } + setPackageManagerSetting('userconfig', `${TEST_FOLDER}/empty.npmrc`); + setPackageManagerSetting('globalconfig', `${TEST_FOLDER}/empty-global.npmrc`); + setPackageManagerSetting('cache', `${TEST_FOLDER}/cache`); + setPackageManagerSetting('store_dir', `${TEST_FOLDER}/store`); + setPackageManagerSetting('ignore_scripts', 'true'); + setPackageManagerSetting('prefer_online', 'true'); + + try { + const version: string = ( + await Utilities.executeCommandAndCaptureOutputAsync({ + command: pnpmPath, + args: ['--version'], + workingDirectory: TEST_FOLDER, + environment: fixtureEnvironment, + keepEnvironment: true + }) + ).trim(); + const registryPackages: IRegistryPackage[] = []; + async function addPackageAsync( + name: string, + packageVersion: string, + dependencies: Record = {} + ): Promise { + const manifest: IPackageJson = { + name, + version: packageVersion, + dependencies, + scripts: { postinstall: LIFECYCLE_SCRIPT } + }; + const folder: string = `${TEST_FOLDER}/tarballs/${name}-${packageVersion}`; + await JsonFile.saveAsync(manifest, `${folder}/package/package.json`, { ensureFolderExists: true }); + const tarballPath: string = `${folder}/package.tgz`; + await tar.create({ cwd: folder, file: tarballPath, gzip: true, portable: true }, ['package']); + const tarball: Buffer = await FileSystem.readFileToBufferAsync(tarballPath); + registryPackages.push({ + manifest, + tarball, + integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}` + }); + } + + for (const name of ['child', 'leaf', 'unrelated-child', 'overridden']) { + for (const packageVersion of ['1.0.0', '1.9.0']) { + await addPackageAsync(`fixture-${name}`, packageVersion); + } + } + for (const packageVersion of ['1.0.0', '1.9.0']) { + await addPackageAsync('fixture-grandchild', packageVersion, { 'fixture-leaf': '^1.0.0' }); + await addPackageAsync('fixture-unrelated', packageVersion, { 'fixture-unrelated-child': '^1.0.0' }); + } + for (const packageVersion of ['1.0.0', '2.0.0']) { + await addPackageAsync('fixture-parent', packageVersion, { 'fixture-grandchild': '^1.0.0' }); + await addPackageAsync('fixture-incompatible', packageVersion); + await addPackageAsync('fixture-root', packageVersion, { + 'fixture-child': '^1.0.0', + 'fixture-parent': `^${packageVersion}`, + 'fixture-incompatible': `^${packageVersion}`, + 'fixture-overridden': '^1.0.0' + }); + } + + server = createServer(); + await new Promise((resolve, reject) => { + server!.once('error', reject); + server!.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a loopback TCP server'); + } + const registryUrl: string = `http://127.0.0.1:${address.port}`; + const expectedMetadataPaths: Set = new Set( + registryPackages.map(({ manifest }) => `/${manifest.name}`) + ); + const expectedTarballPaths: Set = new Set( + registryPackages.map(({ manifest }) => `/${manifest.name}/-/${manifest.version}.tgz`) + ); + server.on('request', (request, response) => { + const requestPath: string = request.url ?? ''; + if ( + request.method !== 'GET' || + request.headers.host !== `127.0.0.1:${address.port}` || + (!expectedMetadataPaths.has(requestPath) && !expectedTarballPaths.has(requestPath)) + ) { + unexpectedRequests.push(`${request.method} ${request.headers.host}${requestPath}`); + response.writeHead(404); + response.end(); + return; + } + const tarball: IRegistryPackage | undefined = registryPackages.find( + ({ manifest }) => requestPath === `/${manifest.name}/-/${manifest.version}.tgz` + ); + if (tarball) { + tarballRequests++; + requestedTarballs.push(requestPath); + response.end(tarball.tarball); + return; + } + const packages: IRegistryPackage[] = registryPackages.filter( + ({ manifest }) => + requestPath === `/${manifest.name}` && (newerVersionsAvailable || manifest.version === '1.0.0') + ); + if (packages.length === 0) { + response.writeHead(404); + response.end(); + return; + } + metadataRequests.add(requestPath); + response.setHeader('Content-Type', 'application/json'); + // The available versions change between installs, so a metadata cache must revalidate them. + response.setHeader('Cache-Control', 'no-store'); + response.end( + JSON.stringify({ + name: packages[0].manifest.name, + 'dist-tags': { latest: packages[packages.length - 1].manifest.version }, + versions: Object.fromEntries( + packages.map(({ manifest, integrity }) => [ + manifest.version, + { + ...manifest, + dist: { integrity, tarball: `${registryUrl}/${manifest.name}/-/${manifest.version}.tgz` } + } + ]) + ), + time: Object.fromEntries( + packages.map(({ manifest }) => [manifest.version, '2020-01-01T00:00:00Z']) + ) + }) + ); + }); + setPackageManagerSetting('registry', registryUrl); + + await JsonFile.saveAsync( + { rushVersion: '5.0.0', pnpmVersion: version, projects: [] }, + `${TEST_FOLDER}/rush.json` + ); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${TEST_FOLDER}/rush.json` + ); + Object.defineProperty(rushConfiguration, 'packageManagerToolFilename', { value: pnpmPath }); + jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined); + // Without an explicit environment, child_process inherits the CI host's environment. + // Keep real execution and all command options, supplying only the fixture's isolated environment. + const executeCommandAsync: typeof Utilities.executeCommandAsync = Utilities.executeCommandAsync; + jest.spyOn(Utilities, 'executeCommandAsync').mockImplementation(async (options) => { + expect(options.command).toBe(pnpmPath); + await executeCommandAsync({ ...options, environment: fixtureEnvironment }); + }); + await FileSystem.writeFileAsync( + `${rushConfiguration.commonRushConfigFolder}/.npmrc`, + `registry=${registryUrl}\n`, + { ensureFolderExists: true } + ); + + function packageJson(rootVersion: string): IPnpmFixturePackageJson { + return { + name: 'fixture-autoinstaller', + version: '1.0.0', + private: true, + dependencies: { 'fixture-root': rootVersion, 'fixture-unrelated': '^1.0.0' }, + scripts: { postinstall: LIFECYCLE_SCRIPT }, + pnpm: { overrides: { 'fixture-overridden': '1.0.0' } } + }; + } + + async function readLockfileAsync(folder: string): Promise { + return yaml.load(await FileSystem.readFileAsync(`${folder}/pnpm-lock.yaml`)) as ILockfile; + } + + function lockedPackage( + lockfile: ILockfile, + name: string, + packageVersion: string, + phase: string, + baseline?: ILockfile + ): unknown { + const key: string = `${name}@${packageVersion}`; + const packageEntry = lockfile.packages[key] ?? lockfile.packages[`/${key}`]; + if (packageEntry === undefined) { + const diagnostic: string = JSON.stringify( + { pnpmVersion: version, phase, expectedPackage: key, baseline, lockfile }, + undefined, + 2 + ); + const summary: string = `PNPM ${version}: missing ${key} during ${phase}.`; + if (Buffer.byteLength(diagnostic, 'utf8') > 64 * 1024) { + throw new Error(`${summary} Fixture lockfile diagnostics exceed the 64 KiB limit.`); + } + throw new Error(`${summary}\n${diagnostic}`); + } + expect(packageEntry).toBeDefined(); + const published: IRegistryPackage = registryPackages.find( + ({ manifest }) => manifest.name === name && manifest.version === packageVersion + )!; + expect(packageEntry.resolution.integrity).toBe(published.integrity); + return { packageEntry, snapshot: lockfile.snapshots?.[key] }; + } + + const autoinstallers: Autoinstaller[] = []; + const initialLockfiles: ILockfile[] = []; + for (const name of ['preserve', 'full-update']) { + const autoinstaller: Autoinstaller = new Autoinstaller({ + autoinstallerName: name, + rushConfiguration, + rushGlobalFolder: {} as RushGlobalFolder, + restrictConsoleOutput: true + }); + await JsonFile.saveAsync(packageJson('1.0.0'), autoinstaller.packageJsonPath, { + ensureFolderExists: true + }); + // PNPM 11+ reads overrides from pnpm-workspace.yaml, while older versions use package.json. + await FileSystem.writeFileAsync( + `${autoinstaller.folderFullPath}/pnpm-workspace.yaml`, + 'packages: []\noverrides:\n fixture-overridden: 1.0.0\n' + ); + await autoinstaller.updateAsync(); + autoinstallers.push(autoinstaller); + initialLockfiles.push(await readLockfileAsync(autoinstaller.folderFullPath)); + } + + newerVersionsAvailable = true; + for (const [index, autoinstaller] of autoinstallers.entries()) { + const preserveLockfile: boolean = index === 0; + await JsonFile.saveAsync(packageJson('2.0.0'), autoinstaller.packageJsonPath); + await autoinstaller.updateAsync(preserveLockfile ? { preserveLockfile: true } : undefined); + const reconciled: ILockfile = await readLockfileAsync(autoinstaller.folderFullPath); + const retainedVersion: string = preserveLockfile ? '1.0.0' : '1.9.0'; + for (const name of ['child', 'grandchild', 'leaf', 'unrelated', 'unrelated-child', 'overridden']) { + const expectedVersion: string = name === 'overridden' ? '1.0.0' : retainedVersion; + const entry: unknown = lockedPackage( + reconciled, + `fixture-${name}`, + expectedVersion, + `${autoinstaller.name}:updated`, + initialLockfiles[index] + ); + if (preserveLockfile || name === 'overridden') { + expect(entry).toEqual( + lockedPackage( + initialLockfiles[index], + `fixture-${name}`, + '1.0.0', + `${autoinstaller.name}:initial` + ) + ); + } + } + for (const name of ['root', 'parent', 'incompatible']) { + lockedPackage( + reconciled, + `fixture-${name}`, + '2.0.0', + `${autoinstaller.name}:updated`, + initialLockfiles[index] + ); + } + expect(reconciled.importers?.['.'].dependencies ?? reconciled.dependencies).toMatchObject({ + 'fixture-root': { specifier: '2.0.0', version: '2.0.0' }, + 'fixture-unrelated': { specifier: '^1.0.0', version: retainedVersion } + }); + expect( + await FileSystem.existsAsync(`${autoinstaller.folderFullPath}/node_modules/rush-autoinstaller.flag`) + ).toBe(false); + } + + const freshFolder: string = `${TEST_FOLDER}/fresh`; + await FileSystem.ensureFolderAsync(freshFolder); + for (const filename of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc']) { + await FileSystem.copyFileAsync({ + sourcePath: `${autoinstallers[0].folderFullPath}/${filename}`, + destinationPath: `${freshFolder}/${filename}` + }); + } + const beforeFrozen: string = await FileSystem.readFileAsync(`${freshFolder}/pnpm-lock.yaml`); + const frozenLockfile: ILockfile = await readLockfileAsync(freshFolder); + const expectedFrozenTarballs: Set = new Set( + registryPackages + .filter(({ manifest }) => { + const key: string = `${manifest.name}@${manifest.version}`; + return !!(frozenLockfile.packages[key] ?? frozenLockfile.packages[`/${key}`]); + }) + .map(({ manifest }) => `/${manifest.name}/-/${manifest.version}.tgz`) + ); + const requestsBeforeFrozen: number = tarballRequests; + expect(await FileSystem.existsAsync(`${freshFolder}/node_modules`)).toBe(false); + expect(await FileSystem.existsAsync(`${TEST_FOLDER}/fresh-store`)).toBe(false); + setPackageManagerSetting('store_dir', `${TEST_FOLDER}/fresh-store`); + await Utilities.executeCommandAsync({ + command: pnpmPath, + args: ['install', '--frozen-lockfile'], + workingDirectory: freshFolder, + environment: fixtureEnvironment, + keepEnvironment: true + }); + expect(tarballRequests).toBeGreaterThan(requestsBeforeFrozen); + expect(new Set(requestedTarballs.slice(requestsBeforeFrozen))).toEqual(expectedFrozenTarballs); + expect(metadataRequests).toEqual(expectedMetadataPaths); + expect(unexpectedRequests).toEqual([]); + expect(await FileSystem.readFileAsync(`${freshFolder}/pnpm-lock.yaml`)).toBe(beforeFrozen); + let manifestPath: string = `${freshFolder}/package.json`; + for (const [name, expectedVersion] of [ + ['root', '2.0.0'], + ['parent', '2.0.0'], + ['grandchild', '1.0.0'], + ['leaf', '1.0.0'] + ]) { + manifestPath = createRequire(manifestPath).resolve(`fixture-${name}/package.json`); + expect((await JsonFile.loadAsync(manifestPath)).version).toBe(expectedVersion); + } + expect(await FileSystem.existsAsync(`${TEST_FOLDER}/lifecycle-ran`)).toBe(false); + } finally { + jest.restoreAllMocks(); + if (server) { + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + } + await FileSystem.deleteFolderAsync(TEST_FOLDER); + } + }, 120_000); +}); From ff161b36810284d618c89ce1583d3fdb593c805c Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:15:06 +0000 Subject: [PATCH 2/2] test(rush): include lockfile preservation in CLI snapshots Regenerate the help text and CLI specification snapshots for the new opt-in flag. Preserve the command behavior and all real-manager fixture assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- .../test/__snapshots__/RushCommandLine.test.ts.snap | 8 ++++++++ .../test/__snapshots__/CommandLineHelp.test.ts.snap | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index d913bb774e3..4d7266eb620 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -987,6 +987,14 @@ Object { "required": true, "shortName": undefined, }, + Object { + "description": "Reconcile package.json using the existing lockfile instead of deleting it for a full update. Only supported for PNPM and requires an existing lockfile. Runs a non-frozen install; this is not a guarantee that all existing resolutions remain unchanged. Review the lockfile diff.", + "environmentVariable": undefined, + "kind": "Flag", + "longName": "--preserve-lockfile", + "required": false, + "shortName": undefined, + }, ], }, Object { diff --git a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index bed4fdd30af..341b780df79 100644 --- a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -1431,15 +1431,23 @@ Optional arguments: exports[`CommandLineHelp prints the help for each action: update-autoinstaller 1`] = ` "usage: rush update-autoinstaller [-h] --name AUTOINSTALLER_NAME + [--preserve-lockfile] + Use this command to regenerate the shrinkwrap file for an autoinstaller -folder. +folder. By default, the existing lockfile is deleted to perform a full update. Optional arguments: -h, --help Show this help message and exit. --name AUTOINSTALLER_NAME The name of the autoinstaller, which must be one of the folders under common/autoinstallers. + --preserve-lockfile Reconcile package.json using the existing lockfile + instead of deleting it for a full update. Only + supported for PNPM and requires an existing lockfile. + Runs a non-frozen install; this is not a guarantee + that all existing resolutions remain unchanged. + Review the lockfile diff. " `;