diff --git a/common/changes/@microsoft/rush/main_2026-01-31-22-28.json b/common/changes/@microsoft/rush/main_2026-01-31-22-28.json new file mode 100644 index 00000000000..8f3529a2aa7 --- /dev/null +++ b/common/changes/@microsoft/rush/main_2026-01-31-22-28.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add catalog support to `rush-pnpm update`.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1f8def84f93..b0169614eea 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1183,7 +1183,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly resolutionMode: PnpmResolutionMode | undefined; readonly strictPeerDependencies: boolean; readonly unsupportedPackageJsonSettings: unknown | undefined; - updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void; + updateGlobalCatalogsAsync(catalogs: Record> | undefined): Promise; + updateGlobalOnlyBuiltDependenciesAsync(onlyBuiltDependencies: string[] | undefined): Promise; updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void; readonly useWorkspaces: boolean; } diff --git a/libraries/rush-lib/config/heft.json b/libraries/rush-lib/config/heft.json index d361c6eb55b..4f76fe45dcf 100644 --- a/libraries/rush-lib/config/heft.json +++ b/libraries/rush-lib/config/heft.json @@ -30,7 +30,7 @@ { "sourcePath": "src/cli/test", "destinationFolders": ["lib-intermediate-commonjs/cli/test"], - "fileExtensions": [".js"] + "fileExtensions": [".js", ".yaml"] }, { "sourcePath": "src/logic/pnpm/test", diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index 1980c57f085..7b8c3d04606 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -32,6 +32,7 @@ import type { IInstallManagerOptions } from '../logic/base/BaseInstallManagerTyp import { Utilities } from '../utilities/Utilities'; import type { Subspace } from '../api/Subspace'; import type { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration'; +import { PnpmWorkspaceFile } from '../logic/pnpm/PnpmWorkspaceFile'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; import { initializeDotEnv } from '../logic/dotenv'; @@ -583,7 +584,7 @@ export class RushPnpmCommandLineParser { if (!Objects.areDeepEqual(currentGlobalOnlyBuiltDependencies, newGlobalOnlyBuiltDependencies)) { // Update onlyBuiltDependencies to pnpm configuration file - pnpmOptions?.updateGlobalOnlyBuiltDependencies(newGlobalOnlyBuiltDependencies); + await pnpmOptions?.updateGlobalOnlyBuiltDependenciesAsync(newGlobalOnlyBuiltDependencies); // Rerun installation to update await this._doRushUpdateAsync(); @@ -595,6 +596,29 @@ export class RushPnpmCommandLineParser { } break; } + case 'up': + case 'update': { + const pnpmOptions: PnpmOptionsConfiguration | undefined = this._subspace.getPnpmOptions(); + if (pnpmOptions === undefined) { + break; + } + + const workspaceYamlFilename: string = path.join(subspaceTempFolder, 'pnpm-workspace.yaml'); + const newCatalogs: Record> | undefined = + await PnpmWorkspaceFile.loadCatalogsFromFileAsync(workspaceYamlFilename); + const currentCatalogs: Record> | undefined = + pnpmOptions.globalCatalogs; + + if (!Objects.areDeepEqual(currentCatalogs, newCatalogs)) { + await pnpmOptions.updateGlobalCatalogsAsync(newCatalogs); + + this._terminal.writeWarningLine( + `Rush refreshed the ${RushConstants.pnpmConfigFilename} with updated catalog definitions.\n` + + ` Run "rush update --recheck" to update the lockfile, then commit these changes to Git.` + ); + } + break; + } } } diff --git a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts new file mode 100644 index 00000000000..f1e16883abb --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts @@ -0,0 +1,133 @@ +// 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 { FileSystem, JsonFile } from '@rushstack/node-core-library'; +import { TestUtilities } from '@rushstack/heft-config-file'; +import { RushConfiguration } from '../../api/RushConfiguration'; + +const MONOREPO_ROOT: string = path.dirname( + RushConfiguration.tryFindRushJsonLocation({ startingFolder: __dirname })! +); +const CATALOG_SYNC_REPO_PATH: string = `${__dirname}/catalogSyncTestRepo`; + +describe('RushPnpmCommandLineParser', () => { + describe('catalog syncing', () => { + const testRepoPath: string = `${MONOREPO_ROOT}/temp/catalog-sync-test-repo`; + const pnpmConfigPath: string = `${testRepoPath}/common/config/rush/pnpm-config.json`; + const pnpmWorkspacePath: string = `${testRepoPath}/common/temp/pnpm-workspace.yaml`; + + beforeEach(async () => { + await FileSystem.copyFilesAsync({ sourcePath: CATALOG_SYNC_REPO_PATH, destinationPath: testRepoPath }); + + // common/temp is gitignored so it is not part of the static repo; copy the initial workspace file here + await FileSystem.copyFilesAsync({ + sourcePath: `${CATALOG_SYNC_REPO_PATH}/pnpm-workspace.yaml`, + destinationPath: pnpmWorkspacePath + }); + }); + + afterEach(async () => { + await FileSystem.deleteFolderAsync(testRepoPath); + }); + + it('syncs updated catalogs from pnpm-workspace.yaml to pnpm-config.json', async () => { + const updatedWorkspaceYaml = `packages: + - '../../apps/*' +catalogs: + default: + react: ^18.2.0 + react-dom: ^18.2.0 + typescript: ~5.3.0 + frontend: + vue: ^3.4.0 +`; + await FileSystem.writeFileAsync(pnpmWorkspacePath, updatedWorkspaceYaml); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${testRepoPath}/rush.json` + ); + + const subspace = rushConfiguration.getSubspace('default'); + const pnpmOptions = subspace.getPnpmOptions(); + + expect(TestUtilities.stripAnnotations(pnpmOptions?.globalCatalogs)).toEqual({ + default: { + react: '^18.0.0', + 'react-dom': '^18.0.0' + } + }); + + const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile'); + const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath); + + await pnpmOptions?.updateGlobalCatalogsAsync(newCatalogs); + + const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${testRepoPath}/rush.json` + ); + const updatedSubspace = updatedRushConfiguration.getSubspace('default'); + const updatedPnpmOptions = updatedSubspace.getPnpmOptions(); + + expect(TestUtilities.stripAnnotations(updatedPnpmOptions?.globalCatalogs)).toEqual({ + default: { + react: '^18.2.0', + 'react-dom': '^18.2.0', + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0' + } + }); + }); + + it('does not update pnpm-config.json when catalogs are unchanged', async () => { + const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile'); + const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${testRepoPath}/rush.json` + ); + const subspace = rushConfiguration.getSubspace('default'); + const pnpmOptions = subspace.getPnpmOptions(); + + await pnpmOptions?.updateGlobalCatalogsAsync(newCatalogs); + + const savedConfig = JsonFile.load(pnpmConfigPath); + expect(savedConfig.globalCatalogs).toEqual({ + default: { + react: '^18.0.0', + 'react-dom': '^18.0.0' + } + }); + }); + + it('removes catalogs when pnpm-workspace.yaml has no catalogs', async () => { + const workspaceWithoutCatalogs = `packages: + - '../../apps/*' +`; + await FileSystem.writeFileAsync(pnpmWorkspacePath, workspaceWithoutCatalogs); + + const { PnpmWorkspaceFile } = require('../../logic/pnpm/PnpmWorkspaceFile'); + const newCatalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(pnpmWorkspacePath); + + expect(newCatalogs).toBeUndefined(); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${testRepoPath}/rush.json` + ); + const subspace = rushConfiguration.getSubspace('default'); + const pnpmOptions = subspace.getPnpmOptions(); + + await pnpmOptions?.updateGlobalCatalogsAsync(newCatalogs); + + const updatedRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${testRepoPath}/rush.json` + ); + const updatedSubspace = updatedRushConfiguration.getSubspace('default'); + const updatedPnpmOptions = updatedSubspace.getPnpmOptions(); + + expect(updatedPnpmOptions?.globalCatalogs).toBeUndefined(); + }); + }); +}); diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..afa5c0bf4c8 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/common/config/rush/pnpm-config.json @@ -0,0 +1,8 @@ +{ + "globalCatalogs": { + "default": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + } +} diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/pnpm-workspace.yaml b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/pnpm-workspace.yaml new file mode 100644 index 00000000000..c807b81d31e --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - '../../apps/*' +catalogs: + default: + react: ^18.0.0 + react-dom: ^18.0.0 diff --git a/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json new file mode 100644 index 00000000000..90cd8a844c4 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/catalogSyncTestRepo/rush.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.166.0", + "pnpmVersion": "10.28.1", + "nodeSupportedVersionRange": ">=18.0.0", + "projects": [] +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 026ecd29339..efcfccc50a0 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -535,18 +535,32 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration public updateGlobalPatchedDependencies(patchedDependencies: Record | undefined): void { this._globalPatchedDependencies = patchedDependencies; this._json.globalPatchedDependencies = patchedDependencies; - if (this.jsonFilename) { - JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true }); - } + JsonFile.save(this._json, this.jsonFilename as string, { updateExistingFile: true }); } /** * Updates globalOnlyBuiltDependencies field of the PNPM options in the common/config/rush/pnpm-config.json file. */ - public updateGlobalOnlyBuiltDependencies(onlyBuiltDependencies: string[] | undefined): void { + public async updateGlobalOnlyBuiltDependenciesAsync( + onlyBuiltDependencies: string[] | undefined + ): Promise { this._json.globalOnlyBuiltDependencies = onlyBuiltDependencies; - if (this.jsonFilename) { - JsonFile.save(this._json, this.jsonFilename, { updateExistingFile: true }); - } + await JsonFile.saveAsync(this._json, this.jsonFilename as string, { + updateExistingFile: true, + ignoreUndefinedValues: true + }); + } + + /** + * Updates globalCatalogs field of the PNPM options in the common/config/rush/pnpm-config.json file. + */ + public async updateGlobalCatalogsAsync( + catalogs: Record> | undefined + ): Promise { + this._json.globalCatalogs = catalogs; + await JsonFile.saveAsync(this._json, this.jsonFilename as string, { + updateExistingFile: true, + ignoreUndefinedValues: true + }); } } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts index f2d86e24dc5..fd745ce5c97 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmWorkspaceFile.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; -import { Sort, Import, Path } from '@rushstack/node-core-library'; +import { FileSystem, Sort, Import, Path } from '@rushstack/node-core-library'; import { BaseWorkspaceFile } from '../base/BaseWorkspaceFile'; import { PNPM_SHRINKWRAP_YAML_FORMAT } from './PnpmYamlCommon'; @@ -29,7 +29,7 @@ const globEscape: (unescaped: string) => string = require('glob-escape'); // No interface IPnpmWorkspaceYaml { /** The list of local package directories */ packages: string[]; - /** Catalog definitions for centralized version management */ + /** Named catalog definitions for centralized version management */ catalogs?: Record>; } @@ -56,6 +56,33 @@ export class PnpmWorkspaceFile extends BaseWorkspaceFile { this._catalogs = undefined; } + /** + * Loads and returns the catalogs section from an existing pnpm-workspace.yaml file. + * This method handles both the singular 'catalog' field (for the default catalog) and + * the plural 'catalogs' field (for named catalogs), merging them into a single object. + * + * @param workspaceYamlFilename - The path to the pnpm-workspace.yaml file + * @returns The catalogs object, or undefined if the file doesn't exist or has no catalogs + */ + public static async loadCatalogsFromFileAsync( + workspaceYamlFilename: string + ): Promise> | undefined> { + let content: string; + try { + content = await FileSystem.readFileAsync(workspaceYamlFilename); + } catch (error) { + if (FileSystem.isNotExistError(error)) { + return undefined; + } else { + throw error; + } + } + + const parsed: IPnpmWorkspaceYaml | undefined = yamlModule.load(content) as IPnpmWorkspaceYaml | undefined; + + return parsed?.catalogs; + } + /** * Sets the catalog definitions for the workspace. * @param catalogs - A map of catalog name to package versions diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts index 369054709ad..56ddb32e90b 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmOptionsConfiguration.test.ts @@ -2,12 +2,23 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { PnpmOptionsConfiguration } from '../PnpmOptionsConfiguration'; import { TestUtilities } from '@rushstack/heft-config-file'; +import { RushConfiguration } from '../../../api/RushConfiguration'; -const fakeCommonTempFolder: string = path.join(__dirname, 'common', 'temp'); +const MONOREPO_ROOT: string = path.dirname( + RushConfiguration.tryFindRushJsonLocation({ startingFolder: __dirname })! +); +const TEST_TEMP_FOLDER: string = `${MONOREPO_ROOT}/temp/pnpm-config-update-test`; + +const fakeCommonTempFolder: string = `${__dirname}/common/temp`; describe(PnpmOptionsConfiguration.name, () => { + afterEach(async () => { + await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER); + }); + it('throw error if pnpm-config.json does not exist', () => { expect(() => { PnpmOptionsConfiguration.loadFromJsonFileOrThrow( @@ -122,4 +133,171 @@ describe(PnpmOptionsConfiguration.name, () => { } }); }); + + describe('updateGlobalCatalogs', () => { + it('updates and saves globalCatalogs to pnpm-config.json', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-update-test.json`; + + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0', + 'react-dom': '^18.2.0' + }, + frontend: { + vue: '^3.4.0' + } + }; + await pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + }); + + it('handles undefined catalogs', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-undefined-test.json`; + + const initialConfig = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + await pnpmConfiguration.updateGlobalCatalogsAsync(undefined); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(reloadedConfig.globalCatalogs).toBeUndefined(); + }); + }); + + describe('$schema handling', () => { + it('does not fail when $schema is undefined', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-no-schema.json`; + + const configWithoutSchema = { + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(configWithoutSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + + await expect(pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs)).resolves.not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(reloadedConfig.globalCatalogs)).toEqual(updatedCatalogs); + }); + + it('preserves $schema when it exists', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-with-schema.json`; + + const configWithSchema = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalCatalogs: { + default: { + react: '^18.0.0' + } + } + }; + await JsonFile.saveAsync(configWithSchema, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + const updatedCatalogs = { + default: { + react: '^18.2.0' + } + }; + await pnpmConfiguration.updateGlobalCatalogsAsync(updatedCatalogs); + + const savedConfig = await JsonFile.loadAsync(testConfigPath); + expect(savedConfig.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + }); + + it('handles undefined in updateGlobalOnlyBuiltDependenciesAsync', async () => { + const testConfigPath: string = `${TEST_TEMP_FOLDER}/pnpm-config-undefined-test.json`; + + const initialConfig = { + $schema: 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json', + globalOnlyBuiltDependencies: ['node-gyp', 'esbuild'] + }; + await JsonFile.saveAsync(initialConfig, testConfigPath, { ensureFolderExists: true }); + + const pnpmConfiguration: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + + expect(TestUtilities.stripAnnotations(pnpmConfiguration.globalOnlyBuiltDependencies)).toEqual([ + 'node-gyp', + 'esbuild' + ]); + + await expect( + pnpmConfiguration.updateGlobalOnlyBuiltDependenciesAsync(undefined) + ).resolves.not.toThrow(); + + const reloadedConfig: PnpmOptionsConfiguration = PnpmOptionsConfiguration.loadFromJsonFileOrThrow( + testConfigPath, + fakeCommonTempFolder + ); + expect(reloadedConfig.globalOnlyBuiltDependencies).toBeUndefined(); + + const savedConfigJson = await JsonFile.loadAsync(testConfigPath); + expect(savedConfigJson.$schema).toBe( + 'https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json' + ); + expect(savedConfigJson.globalOnlyBuiltDependencies).toBeUndefined(); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts index d8cdb149a88..44bfa8a687d 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmWorkspaceFile.test.ts @@ -12,7 +12,9 @@ describe(PnpmWorkspaceFile.name, () => { let mockWriteFile: jest.SpyInstance; let mockReadFile: jest.SpyInstance; + let mockReadFileAsync: jest.SpyInstance; let mockExists: jest.SpyInstance; + let mockExistsAsync: jest.SpyInstance; let writtenContent: string | undefined; beforeEach(() => { @@ -29,7 +31,15 @@ describe(PnpmWorkspaceFile.name, () => { // Mock FileSystem.readFile to return the written content mockReadFile = jest.spyOn(FileSystem, 'readFile').mockImplementation(() => { if (writtenContent === undefined) { - throw new Error('File not found'); + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT', errno: -2, syscall: 'open' }); + } + return writtenContent; + }); + + // Mock async version for loadCatalogsFromFileAsync + mockReadFileAsync = jest.spyOn(FileSystem, 'readFileAsync').mockImplementation(async () => { + if (writtenContent === undefined) { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT', errno: -2, syscall: 'open' }); } return writtenContent; }); @@ -38,12 +48,19 @@ describe(PnpmWorkspaceFile.name, () => { mockExists = jest.spyOn(FileSystem, 'exists').mockImplementation(() => { return writtenContent !== undefined; }); + + // Mock async version for loadCatalogsFromFileAsync + mockExistsAsync = jest.spyOn(FileSystem, 'existsAsync').mockImplementation(async () => { + return writtenContent !== undefined; + }); }); afterEach(() => { mockWriteFile.mockRestore(); mockReadFile.mockRestore(); + mockReadFileAsync.mockRestore(); mockExists.mockRestore(); + mockExistsAsync.mockRestore(); }); describe('basic functionality', () => { @@ -180,4 +197,47 @@ describe(PnpmWorkspaceFile.name, () => { expect(content).toMatchSnapshot(); }); }); + + describe('loadCatalogsFromFileAsync', () => { + it('returns undefined for non-existent file', async () => { + const nonExistentFile: string = path.join(tempDir, 'non-existent.yaml'); + const catalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(nonExistentFile); + expect(catalogs).toBeUndefined(); + }); + + it('returns undefined when file has no catalogs', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const catalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(workspaceFilePath); + expect(catalogs).toBeUndefined(); + }); + + it('loads catalogs from existing file', async () => { + const workspaceFile: PnpmWorkspaceFile = new PnpmWorkspaceFile(workspaceFilePath); + workspaceFile.addPackage(path.join(projectsDir, 'app1')); + workspaceFile.setCatalogs({ + default: { + react: '^18.0.0', + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0' + } + }); + workspaceFile.save(workspaceFilePath, { onlyIfChanged: true }); + + const catalogs = await PnpmWorkspaceFile.loadCatalogsFromFileAsync(workspaceFilePath); + expect(catalogs).toEqual({ + default: { + react: '^18.0.0', + typescript: '~5.3.0' + }, + frontend: { + vue: '^3.4.0' + } + }); + }); + }); });