Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "",
"type": "none",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
25 changes: 16 additions & 9 deletions libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jest.mock(`@rushstack/package-deps-hash`, () => {
getGitHashForFiles(filePaths: Iterable<string>): ReadonlyMap<string, string> {
return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath]));
},
hashFilesAsync(rootDirectory: string, filePaths: Iterable<string>): ReadonlyMap<string, string> {
return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath]));
hashFilesAsync(rootDirectory: string, filePaths: Iterable<string>): Promise<ReadonlyMap<string, string>> {
return Promise.resolve(new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath])));
}
};
});
Expand All @@ -33,8 +33,13 @@ import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library';
import type { IDetailedRepoState } from '@rushstack/package-deps-hash';
import { Autoinstaller } from '../../logic/Autoinstaller';
import type { ITelemetryData } from '../../logic/Telemetry';
import { getCommandLineParserInstanceAsync, type SpawnMockArgs, type SpawnMockCall } from './TestUtils';
import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration';
import {
getCommandLineParserInstanceAsync,
type SpawnMockArgs,
type SpawnMockCall,
isolateEnvironmentConfigurationForTests,
type IEnvironmentConfigIsolation
} from './TestUtils';
import { IS_WINDOWS } from '../../utilities/executionUtilities';

// Ordinals into the `mock.calls` array referencing each of the arguments to `spawn`. Note that
Expand Down Expand Up @@ -73,13 +78,15 @@ function expectSpawnToMatchRegexp(spawnCall: SpawnMockCall, expectedRegexp: RegE

describe('RushCommandLineParser', () => {
describe('execute', () => {
let _envIsolation: IEnvironmentConfigIsolation;

beforeEach(() => {
_envIsolation = isolateEnvironmentConfigurationForTests();
});

afterEach(() => {
jest.clearAllMocks();
EnvironmentConfiguration.reset();
jest
.spyOn(EnvironmentConfiguration, 'buildCacheOverrideJsonFilePath', 'get')
.mockReturnValue(undefined);
jest.spyOn(EnvironmentConfiguration, 'buildCacheOverrideJson', 'get').mockReturnValue(undefined);
_envIsolation.restore();
});

describe('in basic repo', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ jest.mock(`@rushstack/package-deps-hash`, () => {
return {
hasSubmodules: false,
hasUncommittedChanges: false,
files: new Map(),
files: new Map([['common/config/rush/npm-shrinkwrap.json', 'hash']]),
symlinks: new Map()
};
},
getRepoChangesAsync(): ReadonlyMap<string, string> {
return new Map();
},
hashFilesAsync(rootDirectory: string, filePaths: Iterable<string>): Promise<ReadonlyMap<string, string>> {
return Promise.resolve(new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath])));
}
};
});
Expand All @@ -28,11 +31,19 @@ import type { IDetailedRepoState } from '@rushstack/package-deps-hash';
import { Autoinstaller } from '../../logic/Autoinstaller';
import type { ITelemetryData } from '../../logic/Telemetry';
import { getCommandLineParserInstanceAsync, setSpawnMock } from './TestUtils';
import { isolateEnvironmentConfigurationForTests, type IEnvironmentConfigIsolation } from './TestUtils';

describe('RushCommandLineParserFailureCases', () => {
describe('execute', () => {
let _envIsolation: IEnvironmentConfigIsolation;

beforeEach(() => {
_envIsolation = isolateEnvironmentConfigurationForTests({ silenceStderrWrite: true });
});

afterEach(() => {
jest.clearAllMocks();
_envIsolation.restore();
jest.restoreAllMocks();
});

describe('in repo plugin custom flushTelemetry', () => {
Expand Down
96 changes: 96 additions & 0 deletions libraries/rush-lib/src/cli/test/TestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack
import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser';
import { FlagFile } from '../../api/FlagFile';
import { RushConstants } from '../../logic/RushConstants';
import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration';

export type SpawnMockArgs = Parameters<typeof import('node:child_process').spawn>;
export type SpawnMock = jest.Mock<ReturnType<typeof import('node:child_process').spawn>, SpawnMockArgs>;
Expand Down Expand Up @@ -37,6 +38,23 @@ export interface IChildProcessModuleMock {
spawn: jest.Mock;
}

const DEFAULT_RUSH_ENV_VARS_TO_CLEAR: ReadonlyArray<string> = [
'RUSH_BUILD_CACHE_OVERRIDE_JSON',
'RUSH_BUILD_CACHE_OVERRIDE_JSON_FILE_PATH',
'RUSH_BUILD_CACHE_CREDENTIAL',
'RUSH_BUILD_CACHE_ENABLED',
'RUSH_BUILD_CACHE_WRITE_ALLOWED'
];

export interface IWithEnvironmentConfigIsolationOptions {
envVarNamesToClear?: ReadonlyArray<string>;
silenceStderrWrite?: boolean;
}

export interface IEnvironmentConfigIsolation {
restore(): void;
}

/**
* Configure the `child_process` `spawn` mock for these tests. This relies on the mock implementation
* in `mock_child_process`.
Expand Down Expand Up @@ -102,3 +120,81 @@ export async function getCommandLineParserInstanceAsync(
repoPath
};
}

/**
* Clears Rush-related environment variables and resets EnvironmentConfiguration for deterministic tests.
*
* Notes:
* - EnvironmentConfiguration caches some values, so we also stub the build-cache override getters.
* - Rush treats any stderr output during `rush test` as a warning, which fails the command; some
* tests intentionally simulate failures and may need stderr silenced.
*/
export function isolateEnvironmentConfigurationForTests(
options: IWithEnvironmentConfigIsolationOptions = {}
): IEnvironmentConfigIsolation {
const envVarNamesToClear: ReadonlyArray<string> =
options.envVarNamesToClear ?? DEFAULT_RUSH_ENV_VARS_TO_CLEAR;

const savedProcessEnv: Record<string, string | undefined> = {};
for (const envVarName of envVarNamesToClear) {
savedProcessEnv[envVarName] = process.env[envVarName];
delete process.env[envVarName];
}

EnvironmentConfiguration.reset();

const restoreFns: Array<() => void> = [];

restoreFns.push(() => {
for (const envVarName of envVarNamesToClear) {
const oldValue: string | undefined = savedProcessEnv[envVarName];
if (oldValue === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = oldValue;
}
}
});

if (options.silenceStderrWrite) {
type StderrWrite = typeof process.stderr.write;
const silentWrite: unknown = (
chunk: string | Uint8Array,
encoding?: BufferEncoding | ((err?: Error | null) => void),
cb?: (err?: Error | null) => void
): boolean => {
if (typeof encoding === 'function') {
encoding(null);
} else {
cb?.(null);
}
return true;
};

const writeSpy: jest.SpyInstance<ReturnType<StderrWrite>, Parameters<StderrWrite>> = jest
.spyOn(process.stderr, 'write')
.mockImplementation(silentWrite as StderrWrite);

restoreFns.push(() => writeSpy.mockRestore());
}

// EnvironmentConfiguration.reset() does not clear cached values for these fields.
const overrideJsonFilePathSpy: jest.SpyInstance<string | undefined, []> = jest
.spyOn(EnvironmentConfiguration, 'buildCacheOverrideJsonFilePath', 'get')
.mockReturnValue(undefined);
const overrideJsonSpy: jest.SpyInstance<string | undefined, []> = jest
.spyOn(EnvironmentConfiguration, 'buildCacheOverrideJson', 'get')
.mockReturnValue(undefined);

restoreFns.push(() => overrideJsonFilePathSpy.mockRestore());
restoreFns.push(() => overrideJsonSpy.mockRestore());
restoreFns.push(() => EnvironmentConfiguration.reset());

return {
restore: () => {
for (let i: number = restoreFns.length - 1; i >= 0; i--) {
restoreFns[i]();
}
}
};
}
Loading