Skip to content

Commit 7a0be79

Browse files
committed
feat: support pnpm global virtual store settings
1 parent 71943f2 commit 7a0be79

21 files changed

Lines changed: 863 additions & 49 deletions

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ These GitHub repositories provide supplementary resources for Rush Stack:
222222
| [/build-tests/rush-amazon-s3-build-cache-plugin-integration-test](./build-tests/rush-amazon-s3-build-cache-plugin-integration-test/) | Tests connecting to an amazon S3 endpoint |
223223
| [/build-tests/rush-lib-declaration-paths-test](./build-tests/rush-lib-declaration-paths-test/) | This project ensures all of the paths in rush-lib/lib/... have imports that resolve correctly. If this project builds, all `lib/**/*.d.ts` files in the `@microsoft/rush-lib` package are valid. |
224224
| [/build-tests/rush-mcp-example-plugin](./build-tests/rush-mcp-example-plugin/) | Example showing how to create a plugin for @rushstack/mcp-server |
225-
| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for non-pnpm package managers in Rush. |
225+
| [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for package managers in Rush. |
226226
| [/build-tests/rush-project-change-analyzer-test](./build-tests/rush-project-change-analyzer-test/) | This is an example project that uses rush-lib's ProjectChangeAnalyzer to |
227227
| [/build-tests/rush-redis-cobuild-plugin-integration-test](./build-tests/rush-redis-cobuild-plugin-integration-test/) | Tests connecting to an redis server |
228228
| [/build-tests/set-webpack-public-path-plugin-test](./build-tests/set-webpack-public-path-plugin-test/) | Building this project tests the set-webpack-public-path-plugin |
@@ -258,4 +258,3 @@ provided by the bot. You will only need to do this once across all repos using o
258258
This repo has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
259259
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
260260
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
261-

build-tests/rush-package-manager-integration-test/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Rush Package Manager Integration Tests
22

3-
This directory contains integration tests for verifying Rush works correctly with different package managers after the tar 7.x upgrade.
3+
This directory contains integration tests for verifying Rush works correctly with different package managers.
44

55
## Background
66

@@ -10,6 +10,8 @@ Rush's npm and yarn modes use temp project tarballs (stored in `common/temp/proj
1010

1111
These tests ensure the tar 7.x upgrade works correctly with these workflows.
1212

13+
The PNPM test additionally verifies Rush's workspace install integration with PNPM's global virtual store.
14+
1315
## Tests
1416

1517
The test suite is written in TypeScript using `@rushstack/node-core-library` for cross-platform compatibility.
@@ -30,6 +32,14 @@ Tests Rush yarn mode by:
3032
- Running `rush install`
3133
- Running `rush build` (verifies everything works end-to-end)
3234

35+
### testPnpmGlobalVirtualStore.ts
36+
Tests Rush pnpm workspace mode with global virtual store by:
37+
- Initializing a Rush repo with `pnpmVersion` configured
38+
- Enabling `useWorkspaces` and `enableGlobalVirtualStore`
39+
- Running `rush update`
40+
- Running `rush install`
41+
- Verifying the generated workspace file, shared PNPM store, dependency links, and build output
42+
3343
## Prerequisites
3444

3545
Before running these tests:
@@ -62,6 +72,7 @@ These integration tests verify:
6272
- ✓ Tarballs are extracted correctly during `rush install`
6373
- ✓ File permissions are preserved (tar filter function works)
6474
- ✓ Dependencies are linked properly between projects
75+
- ✓ PNPM global virtual store is passed through to a real workspace install
6576
- ✓ The complete workflow (update → install → build) succeeds
6677
- ✓ Built code executes correctly
6778

@@ -70,6 +81,7 @@ These integration tests verify:
7081
Each test creates a temporary Rush repository in `/tmp/rush-package-manager-test/`:
7182
- `/tmp/rush-package-manager-test/npm-test-repo/` - npm mode test repository
7283
- `/tmp/rush-package-manager-test/yarn-test-repo/` - yarn mode test repository
84+
- `/tmp/rush-package-manager-test/pnpm-global-virtual-store-test-repo/` - pnpm global virtual store test repository
7385

7486
These directories are cleaned up at the start of each test run.
7587

@@ -86,6 +98,7 @@ The tests use:
8698
The tar library is used in:
8799
- `libraries/rush-lib/src/logic/TempProjectHelper.ts` - Creates tarballs
88100
- `libraries/rush-lib/src/logic/npm/NpmLinkManager.ts` - Extracts tarballs
101+
- `libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts` - Generates PNPM workspace files
89102

90103
## Troubleshooting
91104

build-tests/rush-package-manager-integration-test/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "rush-package-manager-integration-test",
33
"version": "1.0.0",
44
"private": true,
5-
"description": "Integration tests for non-pnpm package managers in Rush.",
5+
"description": "Integration tests for package managers in Rush.",
66
"license": "MIT",
77
"scripts": {
88
"_phase:build": "heft build --clean",

build-tests/rush-package-manager-integration-test/src/TestHelper.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
import * as path from 'node:path';
55
import type * as child_process from 'node:child_process';
66

7-
import { FileSystem, Executable, JsonFile, type JsonObject } from '@rushstack/node-core-library';
7+
import {
8+
FileSystem,
9+
Executable,
10+
JsonFile,
11+
type FileSystemStats,
12+
type JsonObject
13+
} from '@rushstack/node-core-library';
814
import type { ITerminal } from '@rushstack/terminal';
915

1016
/**
@@ -23,14 +29,19 @@ export class TestHelper {
2329
/**
2430
* Execute a Rush command using the locally-built Rush
2531
*/
26-
public async executeRushAsync(args: string[], workingDirectory: string): Promise<void> {
32+
public async executeRushAsync(
33+
args: string[],
34+
workingDirectory: string,
35+
environment?: NodeJS.ProcessEnv
36+
): Promise<void> {
2737
this._terminal.writeLine(`Executing: ${process.argv0} ${this._rushBinPath} ${args.join(' ')}`);
2838

2939
const childProcess: child_process.ChildProcess = Executable.spawn(
3040
process.argv0,
3141
[this._rushBinPath, ...args],
3242
{
3343
currentWorkingDirectory: workingDirectory,
44+
environment,
3445
stdio: 'inherit'
3546
}
3647
);
@@ -45,7 +56,7 @@ export class TestHelper {
4556
*/
4657
public async createTestRepoAsync(
4758
testRepoPath: string,
48-
packageManagerType: 'npm' | 'yarn',
59+
packageManagerType: 'npm' | 'pnpm' | 'yarn',
4960
packageManagerVersion: string
5061
): Promise<void> {
5162
// Clean up previous test run and create empty test repo directory
@@ -66,6 +77,10 @@ export class TestHelper {
6677
delete rushJson.pnpmVersion;
6778
delete rushJson.yarnVersion;
6879
rushJson.npmVersion = packageManagerVersion;
80+
} else if (packageManagerType === 'pnpm') {
81+
delete rushJson.npmVersion;
82+
delete rushJson.yarnVersion;
83+
rushJson.pnpmVersion = packageManagerVersion;
6984
} else if (packageManagerType === 'yarn') {
7085
delete rushJson.pnpmVersion;
7186
delete rushJson.npmVersion;
@@ -151,8 +166,9 @@ export class TestHelper {
151166
// Verify symlinks resolve correctly for local dependencies
152167
if (dep.startsWith('test-project-')) {
153168
const depRealPath: string = await FileSystem.getRealPathAsync(depPath);
154-
const expectedRealPath: string = path.join(testRepoPath, 'projects', dep);
155-
if (depRealPath !== expectedRealPath) {
169+
const expectedPath: string = path.join(testRepoPath, 'projects', dep);
170+
const expectedRealPath: string = await FileSystem.getRealPathAsync(expectedPath);
171+
if (!(await this._doPathsReferToSameObjectAsync(depPath, expectedPath))) {
156172
throw new Error(
157173
`ERROR: Symlink for ${dep} does not resolve correctly!\n` +
158174
`Expected: ${expectedRealPath}\n` +
@@ -164,6 +180,85 @@ export class TestHelper {
164180
this._terminal.writeLine('✓ Dependencies installed correctly');
165181
}
166182

183+
private async _doPathsReferToSameObjectAsync(path1: string, path2: string): Promise<boolean> {
184+
const path1Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path1);
185+
const path2Stats: FileSystemStats = await FileSystem.getStatisticsAsync(path2);
186+
return path1Stats.dev === path2Stats.dev && path1Stats.ino === path2Stats.ino;
187+
}
188+
189+
/**
190+
* Verify that PNPM's global virtual store was enabled and moved out of the workspace node_modules folder.
191+
*/
192+
public async verifyPnpmGlobalVirtualStoreAsync(
193+
testRepoPath: string,
194+
sharedStorePath: string
195+
): Promise<void> {
196+
this._terminal.writeLine('\nVerifying PNPM global virtual store structure...');
197+
198+
const workspaceFilePath: string = path.join(testRepoPath, 'common/temp/pnpm-workspace.yaml');
199+
const workspaceFileContents: string = await FileSystem.readFileAsync(workspaceFilePath);
200+
if (!workspaceFileContents.includes('enableGlobalVirtualStore: true')) {
201+
throw new Error(`ERROR: enableGlobalVirtualStore was not written to ${workspaceFilePath}`);
202+
}
203+
204+
const localVirtualStorePath: string = path.join(testRepoPath, 'common/temp/node_modules/.pnpm');
205+
if (await FileSystem.existsAsync(localVirtualStorePath)) {
206+
const localVirtualStoreItemNames: string[] =
207+
await FileSystem.readFolderItemNamesAsync(localVirtualStorePath);
208+
const unexpectedLocalPackageFolders: string[] = localVirtualStoreItemNames.filter(
209+
(itemName) => itemName !== 'lock.yaml' && itemName !== 'node_modules'
210+
);
211+
if (unexpectedLocalPackageFolders.length > 0) {
212+
throw new Error(
213+
`ERROR: Expected ${localVirtualStorePath} to omit package instance folders, but found: ` +
214+
unexpectedLocalPackageFolders.join(', ')
215+
);
216+
}
217+
}
218+
219+
if (!(await FileSystem.existsAsync(sharedStorePath))) {
220+
throw new Error(`ERROR: Shared PNPM store was not created at ${sharedStorePath}`);
221+
}
222+
223+
const sharedStoreItemNames: string[] = await FileSystem.readFolderItemNamesAsync(sharedStorePath);
224+
if (sharedStoreItemNames.length === 0) {
225+
throw new Error(`ERROR: Shared PNPM store is empty at ${sharedStorePath}`);
226+
}
227+
228+
const globalVirtualStorePath: string = await this._findPnpmGlobalVirtualStorePathAsync(sharedStorePath);
229+
if (!(await FileSystem.existsAsync(globalVirtualStorePath))) {
230+
throw new Error(
231+
`ERROR: Expected PNPM global virtual store package links under ${sharedStorePath}, ` +
232+
`but ${globalVirtualStorePath} was not found.`
233+
);
234+
}
235+
236+
const globalVirtualStoreItemNames: string[] =
237+
await FileSystem.readFolderItemNamesAsync(globalVirtualStorePath);
238+
if (globalVirtualStoreItemNames.length === 0) {
239+
throw new Error(
240+
`ERROR: PNPM global virtual store package links folder is empty at ${globalVirtualStorePath}`
241+
);
242+
}
243+
244+
this._terminal.writeLine('✓ PNPM global virtual store structure verified');
245+
}
246+
247+
private async _findPnpmGlobalVirtualStorePathAsync(sharedStorePath: string): Promise<string> {
248+
const sharedStoreVersionFolderNames: string[] =
249+
await FileSystem.readFolderItemNamesAsync(sharedStorePath);
250+
for (const folderName of sharedStoreVersionFolderNames) {
251+
if (folderName.startsWith('v')) {
252+
const linksPath: string = path.join(sharedStorePath, folderName, 'links');
253+
if (await FileSystem.existsAsync(linksPath)) {
254+
return linksPath;
255+
}
256+
}
257+
}
258+
259+
return path.join(sharedStorePath, '<version>', 'links');
260+
}
261+
167262
/**
168263
* Verify that build outputs were created
169264
*/

build-tests/rush-package-manager-integration-test/src/runTests.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal';
55

66
import { testNpmModeAsync } from './testNpmMode';
7+
import { testPnpmGlobalVirtualStoreAsync } from './testPnpmGlobalVirtualStore';
78
import { testYarnModeAsync } from './testYarnMode';
89

910
/**
@@ -17,7 +18,7 @@ async function runTestsAsync(): Promise<void> {
1718
terminal.writeLine('==========================================');
1819
terminal.writeLine('');
1920
terminal.writeLine('These tests verify that the tar 7.x upgrade works correctly');
20-
terminal.writeLine('with different Rush package managers (npm, yarn).');
21+
terminal.writeLine('with different Rush package managers (npm, pnpm, yarn).');
2122
terminal.writeLine('');
2223
terminal.writeLine('Tests will:');
2324
terminal.writeLine(' 1. Create Rush repos using locally-built Rush');
@@ -45,6 +46,20 @@ async function runTestsAsync(): Promise<void> {
4546
terminal.writeErrorLine(String(error));
4647
}
4748

49+
// Run pnpm global virtual store test
50+
terminal.writeLine('==========================================');
51+
terminal.writeLine('Running PNPM global virtual store test...');
52+
terminal.writeLine('==========================================');
53+
try {
54+
await testPnpmGlobalVirtualStoreAsync(terminal);
55+
testsPassed++;
56+
} catch (error) {
57+
testsFailed++;
58+
failedTests.push('PNPM global virtual store');
59+
terminal.writeErrorLine('⚠️ PNPM global virtual store test FAILED');
60+
terminal.writeErrorLine(String(error));
61+
}
62+
4863
// Run yarn mode test
4964
terminal.writeLine('==========================================');
5065
terminal.writeLine('Running Yarn mode test...');
@@ -81,6 +96,7 @@ async function runTestsAsync(): Promise<void> {
8196
terminal.writeLine('');
8297
terminal.writeLine('The tar 7.x upgrade is working correctly with:');
8398
terminal.writeLine(' - NPM package manager');
99+
terminal.writeLine(' - PNPM global virtual store');
84100
terminal.writeLine(' - Yarn package manager');
85101
terminal.writeLine('');
86102
process.exit(0);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as os from 'node:os';
5+
import * as path from 'node:path';
6+
7+
import { FileSystem, JsonFile, type JsonObject } from '@rushstack/node-core-library';
8+
import type { ITerminal } from '@rushstack/terminal';
9+
10+
import { TestHelper } from './TestHelper';
11+
12+
/**
13+
* Integration test for Rush PNPM workspace mode with PNPM's global virtual store.
14+
* This test verifies that Rush passes enableGlobalVirtualStore through to a real PNPM install.
15+
*/
16+
export async function testPnpmGlobalVirtualStoreAsync(terminal: ITerminal): Promise<void> {
17+
const helper: TestHelper = new TestHelper(terminal);
18+
// Use system temp directory to avoid rush init detecting parent rush.json
19+
const testRepoPath: string = path.join(
20+
os.tmpdir(),
21+
'rush-package-manager-test',
22+
'pnpm-global-virtual-store-test-repo'
23+
);
24+
const sharedStorePath: string = path.join(os.tmpdir(), 'rush-package-manager-test', 'shared-pnpm-store');
25+
const rushEnvironment: NodeJS.ProcessEnv = {
26+
...process.env,
27+
CI: 'false',
28+
PNPM_CONFIG_CI: 'false',
29+
RUSH_PNPM_STORE_PATH: sharedStorePath
30+
};
31+
32+
terminal.writeLine('==========================================');
33+
terminal.writeLine('Rush PNPM Global Virtual Store Integration Test');
34+
terminal.writeLine('==========================================');
35+
terminal.writeLine('');
36+
terminal.writeLine(
37+
'This test verifies that Rush can enable PNPM global virtual store during workspace installs.'
38+
);
39+
terminal.writeLine('');
40+
41+
await helper.createTestRepoAsync(testRepoPath, 'pnpm', '10.12.1');
42+
43+
const pnpmConfigPath: string = path.join(testRepoPath, 'common/config/rush/pnpm-config.json');
44+
const pnpmConfigJson: JsonObject = await JsonFile.loadAsync(pnpmConfigPath);
45+
pnpmConfigJson.useWorkspaces = true;
46+
pnpmConfigJson.enableGlobalVirtualStore = true;
47+
await JsonFile.saveAsync(pnpmConfigJson, pnpmConfigPath, { updateExistingFile: true });
48+
49+
terminal.writeLine('Creating test-project-a...');
50+
await helper.createTestProjectAsync(
51+
testRepoPath,
52+
'test-project-a',
53+
'1.0.0',
54+
{ semver: '^7.5.4' },
55+
`node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { greet: () => \\"Hello from A\\" };');"`
56+
);
57+
58+
terminal.writeLine('Creating test-project-b...');
59+
await helper.createTestProjectAsync(
60+
testRepoPath,
61+
'test-project-b',
62+
'1.0.0',
63+
{
64+
'test-project-a': 'workspace:*',
65+
moment: '^2.29.4'
66+
},
67+
`node -e "const fs = require('fs'); fs.mkdirSync('lib', {recursive: true}); fs.writeFileSync('lib/index.js', 'module.exports = { test: () => \\"Using: \\" + require(\\'test-project-a\\').greet() };');"`
68+
);
69+
70+
await FileSystem.ensureEmptyFolderAsync(sharedStorePath);
71+
72+
terminal.writeLine('');
73+
terminal.writeLine("Running 'rush update' with PNPM global virtual store enabled...");
74+
await helper.executeRushAsync(['update'], testRepoPath, rushEnvironment);
75+
76+
terminal.writeLine('');
77+
terminal.writeLine("Running 'rush install' with PNPM global virtual store enabled...");
78+
await helper.executeRushAsync(['install'], testRepoPath, rushEnvironment);
79+
80+
await helper.verifyPnpmGlobalVirtualStoreAsync(testRepoPath, sharedStorePath);
81+
await helper.verifyDependenciesAsync(testRepoPath, 'test-project-a', ['semver']);
82+
await helper.verifyDependenciesAsync(testRepoPath, 'test-project-b', ['test-project-a']);
83+
84+
terminal.writeLine('');
85+
terminal.writeLine("Running 'rush build'...");
86+
await helper.executeRushAsync(['build'], testRepoPath, rushEnvironment);
87+
88+
await helper.verifyBuildOutputsAsync(testRepoPath, ['test-project-a', 'test-project-b']);
89+
await helper.testBuiltCodeAsync(testRepoPath, 'test-project-b');
90+
91+
terminal.writeLine('');
92+
terminal.writeLine('==========================================');
93+
terminal.writeLine('✓ PNPM Global Virtual Store Integration Test PASSED');
94+
terminal.writeLine('==========================================');
95+
terminal.writeLine('');
96+
terminal.writeLine('PNPM global virtual store works correctly with Rush workspace installs:');
97+
terminal.writeLine(' - Workspace file includes enableGlobalVirtualStore');
98+
terminal.writeLine(' - Shared PNPM store is populated');
99+
terminal.writeLine(' - Dependencies link and resolve correctly');
100+
terminal.writeLine(' - Build completed successfully');
101+
terminal.writeLine('');
102+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add PNPM global virtual store support for workspace installs and avoid unnecessary global package manager lock acquisition.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@microsoft/rush",
10+
"email": "EscapeB@users.noreply.github.com"
11+
}

0 commit comments

Comments
 (0)