diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..4b3bf391a67 --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +/** + * The cross-version launch contract owned by the Rush frontend. + * + * @remarks + * Reporter selection remains in `@microsoft/rush`. The selected `rush-lib` + * receives only the typed producer sink in addition to its existing launch + * options, so an older engine can safely ignore the new property. + */ +export interface IRushFrontendLaunchOptions extends ILaunchOptions { + readonly reporterEventSink: IReporterEventSink; + readonly reporterCloseAsync: () => Promise; +} diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 0cc4436b964..62aef01d11d 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; -import { JsonFile } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { RushConfiguration } from '@microsoft/rush-lib'; import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; @@ -13,6 +13,10 @@ interface IMinimalRushConfigurationJson { rushVersion?: string; } +interface IMinimalExperimentsConfigurationJson { + useRushReporter?: boolean; +} + /** * Represents a minimal subset of the rush.json configuration file. It provides the information necessary to * decide which version of Rush should be installed/used. @@ -20,6 +24,7 @@ interface IMinimalRushConfigurationJson { export class MinimalRushConfiguration { private _rushVersion: string; private _commonRushConfigFolder: string; + private _useRushReporter: boolean; private constructor(minimalRushConfigurationJson: IMinimalRushConfigurationJson, rushJsonFilename: string) { this._rushVersion = @@ -30,6 +35,20 @@ export class MinimalRushConfiguration { 'config', 'rush' ); + + const experimentsJsonFilename: string = path.join( + this._commonRushConfigFolder, + RushConstants.experimentsFilename + ); + const experimentsConfiguration: IMinimalExperimentsConfigurationJson | undefined = + _loadExperimentsConfigurationJson(experimentsJsonFilename); + if ( + experimentsConfiguration?.useRushReporter !== undefined && + typeof experimentsConfiguration.useRushReporter !== 'boolean' + ) { + throw new Error(`The "useRushReporter" setting in "${experimentsJsonFilename}" must be true or false.`); + } + this._useRushReporter = experimentsConfiguration?.useRushReporter === true; } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { @@ -68,6 +87,13 @@ export class MinimalRushConfiguration { public get commonRushConfigFolder(): string { return this._commonRushConfigFolder; } + + /** + * Whether the repository explicitly opted in to the experimental Rush reporter frontend. + */ + public get useRushReporter(): boolean { + return this._useRushReporter; + } } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { @@ -77,3 +103,16 @@ function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigura return undefined; } } + +function _loadExperimentsConfigurationJson( + experimentsJsonFilename: string +): IMinimalExperimentsConfigurationJson | undefined { + try { + return JsonFile.load(experimentsJsonFilename); + } catch (e) { + if (FileSystem.isNotExistError(e)) { + return undefined; + } + throw e; + } +} diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..8d29eac6afa 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,8 +3,7 @@ import * as path from 'node:path'; -import type { ILaunchOptions } from '@microsoft/rush-lib/lib/index'; -import { Colorize } from '@rushstack/terminal'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; @@ -28,7 +27,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; @@ -65,8 +64,7 @@ export class RushCommandSelector { } function _failWithError(message: string): never { - console.log(Colorize.red(message)); - return process.exit(1); + throw new Error(message); } function _getCommandName(): CommandName { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts new file mode 100644 index 00000000000..0fc42146f09 --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IRushReporterHostOptions, + type IInitializedRushReporterHost +} from './RushReporterHost'; +import { RushCommandSelector } from './RushCommandSelector'; +import { RushVersionSelector } from './RushVersionSelector'; +import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + +export interface IRushFrontendOptions { + readonly currentPackageVersion: string; + readonly rushVersionToLoad: string | undefined; + readonly configuration: MinimalRushConfiguration | undefined; + readonly launchOptions: ILaunchOptions; + readonly currentRushLib: typeof import('@microsoft/rush-lib'); + readonly initializeReporterHostAsync?: ( + options: IRushReporterHostOptions + ) => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => void | Promise; + readonly processLifecycle?: IRushFrontendProcessLifecycle; +} + +type RushTerminationSignal = 'SIGINT' | 'SIGTERM'; + +export interface IRushFrontendProcessLifecycle { + registerBeforeExit(listener: () => void): () => void; + registerSignal(signal: RushTerminationSignal, listener: () => void): () => void; + terminate(signal: RushTerminationSignal): void; + setExitCode(exitCode: number): void; + reportCloseError(error: Error): void; +} + +class RushFrontendReporterLifecycle { + private readonly _reporterHost: IInitializedRushReporterHost; + private readonly _processLifecycle: IRushFrontendProcessLifecycle; + private _disposeBeforeExit: (() => void) | undefined; + private readonly _disposeSignalHandlers: Array<() => void> = []; + private _closePromise: Promise | undefined; + + public constructor( + reporterHost: IInitializedRushReporterHost, + processLifecycle: IRushFrontendProcessLifecycle + ) { + this._reporterHost = reporterHost; + this._processLifecycle = processLifecycle; + } + + public start(): void { + this._disposeBeforeExit = this._processLifecycle.registerBeforeExit(() => { + void this.closeAsync().catch((error: Error) => { + this._processLifecycle.reportCloseError(error); + this._processLifecycle.setExitCode(1); + }); + }); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + this._disposeSignalHandlers.push( + this._processLifecycle.registerSignal(signal, () => { + this._disposeSignals(); + void this._closeForSignalAsync(signal); + }) + ); + } + } + + public closeAsync(timeoutMs?: number): Promise { + if (!this._closePromise) { + this._closePromise = Promise.resolve() + .then(() => this._reporterHost.closeAsync(timeoutMs)) + .finally(() => this._dispose()); + } + return this._closePromise; + } + + private _dispose(): void { + this._disposeBeforeExit?.(); + this._disposeBeforeExit = undefined; + this._disposeSignals(); + } + + private _disposeSignals(): void { + for (const dispose of this._disposeSignalHandlers.splice(0)) { + dispose(); + } + } + + private async _closeForSignalAsync(signal: RushTerminationSignal): Promise { + const closeResult: Promise = this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS).then( + () => undefined, + (error: Error) => error + ); + let timeout: ReturnType | undefined; + const deadline: Promise<'deadline'> = new Promise((resolve: (value: 'deadline') => void) => { + timeout = setTimeout(() => resolve('deadline'), DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS); + }); + + const result: Error | 'deadline' | undefined = await Promise.race([closeResult, deadline]); + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (result === 'deadline') { + this._processLifecycle.reportCloseError( + new Error(`Reporter close exceeded the ${DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS}ms signal deadline.`) + ); + } else if (result) { + this._processLifecycle.reportCloseError(result); + } + this._dispose(); + this._processLifecycle.terminate(signal); + } +} + +export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute, + processLifecycle = createProcessLifecycle() + } = options; + + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter, + forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, + selectedRushVersion: rushVersionToLoad + }); + const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled + ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) + : undefined; + reporterLifecycle?.start(); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { + process.argv = stripReporterValueControls( + process.argv, + new Set(reporterHost.selection.reporterValueFlagsToStrip) + ); + } + const reporterCloseAsync: () => Promise = () => + reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const reporterLaunchOptions: IRushFrontendLaunchOptions = { + ...launchOptions, + reporterEventSink: reporterHost.sink, + reporterCloseAsync + }; + + try { + if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { + const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); + await versionSelector.ensureRushVersionInstalledAsync( + rushVersionToLoad, + configuration, + reporterLaunchOptions + ); + } else { + await executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + } + } catch (error) { + try { + await reporterCloseAsync(); + } catch (closeError) { + processLifecycle.reportCloseError(closeError as Error); + processLifecycle.setExitCode(1); + } + throw error; + } +} + +function createProcessLifecycle(): IRushFrontendProcessLifecycle { + return { + registerBeforeExit: (listener: () => void) => { + process.once('beforeExit', listener); + return () => process.off('beforeExit', listener); + }, + registerSignal: (signal: RushTerminationSignal, listener: () => void) => { + process.once(signal, listener); + return () => process.off(signal, listener); + }, + terminate: (signal: RushTerminationSignal) => { + process.kill(process.pid, signal); + }, + setExitCode: (exitCode: number) => { + process.exitCode = exitCode; + }, + reportCloseError: (error: Error) => { + process.stderr.write(`[reporter] Unable to finalize reporters: ${error.message}\n`); + } + }; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts new file mode 100644 index 00000000000..dfa9b84a7e4 --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,653 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + AiReporter, + DefaultInteractiveReporter, + FileReporter, + JsonReporter, + PlaintextReporter, + ReporterHost, + isCiDetected, + isLegacyEmergencyFallbackRequested, + isSupportedLogLevel, + isSupportedReporterName, + parseOutputControl, + separateJsonControls, + shouldRenderAtLogLevel, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterOutputTarget, + type ReporterLogLevel, + type ReporterName +} from '@rushstack/rush-reporter'; + +export interface IRushReporterOutputStream { + readonly isTTY?: boolean; + readonly columns?: number; + write(text: string): unknown; +} + +export interface IRushReporterHostOptions { + readonly argv?: readonly string[]; + readonly env?: Record; + readonly cwd?: string; + readonly stdout?: IRushReporterOutputStream; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; + readonly forceLegacy?: boolean; + readonly selectedRushVersion?: string; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reporterValueFlagsToStrip: readonly string[]; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default'; +} + +export interface IInitializedRushReporterHost { + readonly host: ReporterHost; + readonly sink: IReporterEventSink; + readonly selection: IRushReporterSelection; + closeAsync(timeoutMs?: number): Promise; +} + +const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); +const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; +const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; + +interface IParsedReporterControls { + readonly reporters: readonly string[]; + readonly logLevels: readonly string[]; + readonly outputs: readonly string[]; + readonly quiet: boolean; + readonly verbose: boolean; + readonly debug: boolean; +} + +class LogLevelReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + private readonly _logLevel: ReporterLogLevel; + + public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + this._reporter = reporter; + this._logLevel = logLevel; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if (shouldRenderAtLogLevel(this._logLevel, event)) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + +class ExplicitOutputReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: JsonReporter; + private readonly _filteredReporter: LogLevelReporter; + private readonly _outputPath: string; + private _fileDescriptor: number | undefined; + + public constructor(reporterName: string, outputPath: string, logLevel: ReporterLogLevel) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._fileDescriptor === undefined) { + throw new Error(`Reporter output ${JSON.stringify(this._outputPath)} is not initialized.`); + } + fs.writeSync(this._fileDescriptor, text); + } + }); + this._filteredReporter = new LogLevelReporter(this._reporter, logLevel); + } + + public async initializeAsync(context: IReporterContext): Promise { + await fs.promises.mkdir(path.dirname(this._outputPath), { recursive: true }); + this._fileDescriptor = fs.openSync(this._outputPath, 'w', 0o600); + await this._filteredReporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + this._filteredReporter.report(event); + } + + public async flushAsync(): Promise { + await this._filteredReporter.flushAsync(); + if (this._fileDescriptor !== undefined) { + fs.fsyncSync(this._fileDescriptor); + } + } + + public async closeAsync(): Promise { + try { + await this._filteredReporter.closeAsync(); + } finally { + if (this._fileDescriptor !== undefined) { + fs.closeSync(this._fileDescriptor); + this._fileDescriptor = undefined; + } + } + } +} + +function readValue( + argv: readonly string[], + index: number, + flag: string +): { readonly value: string; readonly consumedNext: boolean } | undefined { + const argument: string = argv[index]; + const prefix: string = `${flag}=`; + if (argument.startsWith(prefix)) { + const value: string = argument.slice(prefix.length); + if (!value) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: false }; + } + if (argument !== flag) { + return undefined; + } + + const value: string | undefined = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: true }; +} + +export function stripReporterValueControls( + argv: readonly string[], + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS +): string[] { + const result: string[] = []; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + result.push(...argv.slice(index)); + break; + } + const equalsIndex: number = argument.indexOf('='); + const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); + if (!valueFlagsToStrip.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + index++; + } + } + return result; +} + +function parseReporterControls( + argv: readonly string[], + includeOutputAndLogLevelControls: boolean, + tolerateMissingReporterValue: boolean = false +): IParsedReporterControls { + const reporters: string[] = []; + const logLevels: string[] = []; + const outputs: string[] = []; + let quiet: boolean = false; + let verbose: boolean = false; + let debug: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + if ( + tolerateMissingReporterValue && + argument === '--reporter' && + (!argv[index + 1] || argv[index + 1].startsWith('-')) + ) { + continue; + } + const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.consumedNext ? 1 : 0; + continue; + } + if (includeOutputAndLogLevelControls) { + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--log-level' + ); + if (logLevel) { + logLevels.push(logLevel.value); + index += logLevel.consumedNext ? 1 : 0; + continue; + } + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--output' + ); + if (output) { + outputs.push(output.value); + index += output.consumedNext ? 1 : 0; + continue; + } + } + + quiet ||= argument === '--quiet' || argument === '-q'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + return { reporters, logLevels, outputs, quiet, verbose, debug }; +} + +function validateReporterControlMultiplicity( + controls: IParsedReporterControls, + includeOutputAndLogLevelControls: boolean +): void { + if (controls.reporters.length > 1) { + throw new Error('--reporter may be specified only once.'); + } + if (includeOutputAndLogLevelControls && controls.logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } +} + +function hasReporterOutputControl(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + const prefix: string = '--output='; + const value: string | undefined = argument.startsWith(prefix) + ? argument.slice(prefix.length) + : argument === '--output' && argv[index + 1] && !argv[index + 1].startsWith('-') + ? argv[index + 1] + : undefined; + if (value && /^(?:file|json):\/\//.test(value)) { + return true; + } + } + return false; +} + +function resolveLogLevel( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean, + useLegacyAliasPrecedence: boolean = false +): ReporterLogLevel { + const requestedLevels: ReporterLogLevel[] = []; + const explicitLogLevel: string | undefined = controls.logLevels[0]; + if (explicitLogLevel !== undefined) { + if (!isSupportedLogLevel(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + requestedLevels.push(explicitLogLevel); + } + if (useLegacyAliasPrecedence && explicitLogLevel === undefined) { + if (controls.debug) { + return 'debug'; + } + if (controls.verbose) { + return 'verbose'; + } + if (controls.quiet) { + return 'quiet'; + } + } + if (controls.quiet) { + requestedLevels.push('quiet'); + } + if (controls.verbose) { + requestedLevels.push('verbose'); + } + if (controls.debug) { + requestedLevels.push('debug'); + } + + const distinctLevels: Set = new Set(requestedLevels); + if (distinctLevels.size > 1) { + throw new Error( + `Contradictory reporter verbosity controls were specified: ${[...distinctLevels].sort().join(', ')}. ` + + 'Specify only one of --log-level, --quiet, --verbose, or --debug.' + ); + } + if (requestedLevels.length > 0) { + return requestedLevels[0]; + } + + const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + if (environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (!isSupportedLogLevel(normalizedLogLevel)) { + throw new Error( + `Unsupported RUSH_LOG_LEVEL value ${JSON.stringify(environmentLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return normalizedLogLevel; + } + + return 'normal'; +} + +function resolveOutputs(outputValues: readonly string[], cwd: string): readonly IReporterOutputTarget[] { + return outputValues.map((value: string) => { + const output: IReporterOutputTarget = parseOutputControl(value); + if (output.reporter !== 'file' && output.reporter !== 'json') { + throw new Error( + `Unsupported --output reporter ${JSON.stringify(output.reporter)}. ` + + 'This rollout stage supports file:// and json:// output targets.' + ); + } + if (!output.target) { + throw new Error(`The --output target must not be empty: ${JSON.stringify(value)}.`); + } + for (const parameterName of Object.keys(output.params)) { + if (parameterName !== 'logLevel') { + throw new Error( + `Unsupported --output query parameter ${JSON.stringify(parameterName)}. ` + + 'The only supported query parameter is logLevel.' + ); + } + } + const outputLogLevel: string | undefined = output.params.logLevel; + if (outputLogLevel !== undefined && !isSupportedLogLevel(outputLogLevel)) { + throw new Error( + `Unsupported --output logLevel ${JSON.stringify(outputLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return { + ...output, + target: path.resolve(cwd, output.target) + }; + }); +} + +export function resolveRushReporterSelection(options: IRushReporterHostOptions = {}): IRushReporterSelection { + const argv: readonly string[] = options.argv ?? process.argv.slice(2); + const env: Record = options.env ?? process.env; + const commandName: 'rush' | 'rush-pnpm' | 'rushx' = options.commandName ?? getCommandName(); + if (commandName !== 'rush') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: separateJsonControls(argv).commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); + const reporterOwnershipEstablished: boolean = + options.repositoryOptIn === true || + hasReporterOutputControl(argv) || + reporterProbe.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + const selectionControls: IParsedReporterControls = reporterOwnershipEstablished + ? parseReporterControls(argv, false) + : reporterProbe; + if (reporterOwnershipEstablished) { + validateReporterControlMultiplicity(selectionControls, false); + } + const reporterValue: string | undefined = reporterOwnershipEstablished + ? selectionControls.reporters[0] + : undefined; + if (reporterValue !== undefined && !isSupportedReporterName(reporterValue)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(reporterValue)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + const requestedReporter: ReporterName | undefined = reporterValue; + + if (isLegacyEmergencyFallbackRequested(env)) { + const reporterValueFlagsToStrip: readonly string[] = + requestedReporter === 'legacy' + ? REPORTER_SELECTION_FLAG + : requestedReporter === undefined + ? [] + : ALL_REPORTER_VALUE_FLAGS; + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip, + reason: 'RUSH_REPORTER=legacy' + }; + } + + if (options.forceLegacy) { + if (requestedReporter !== undefined && requestedReporter !== 'legacy') { + throw new Error( + `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + + `cannot safely use --reporter=${requestedReporter} because this frontend cannot verify its ` + + 'reporter close contract. Remove the explicit reporter request or use the Rush version bundled ' + + 'with this frontend.' + ); + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + if (requestedReporter === undefined) { + const environmentReporter: string | undefined = env.RUSH_REPORTER; + if (environmentReporter?.trim()) { + throw new Error( + `RUSH_REPORTER=${JSON.stringify(environmentReporter)} cannot enable the pre-major reporter path. ` + + 'Use an explicit --reporter option, or set RUSH_REPORTER=legacy for the emergency fallback.' + ); + } + if (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + return { + reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(selectionControls, env, true, true), + outputs: [], + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'repository experiment' + }; + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + if (requestedReporter === 'legacy') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: REPORTER_SELECTION_FLAG, + reason: 'explicit --reporter' + }; + } + + const controls: IParsedReporterControls = parseReporterControls(argv, true); + validateReporterControlMultiplicity(controls, true); + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + if (requestedReporter === 'default' && !stdout.isTTY) { + throw new Error( + '--reporter=default requires an interactive TTY. Use --reporter=plaintext for CI or redirected output.' + ); + } + + return { + reporter: requestedReporter, + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + reason: 'explicit --reporter' + }; +} + +function createPrimaryReporter( + selection: IRushReporterSelection, + stdout: IRushReporterOutputStream, + env: Record +): IReporter | undefined { + switch (selection.reporter) { + case 'default': + return new DefaultInteractiveReporter({ + terminal: { + columns: stdout.columns ?? 80, + isTTY: stdout.isTTY === true, + write: (text: string) => { + stdout.write(text); + } + }, + env + }); + case 'ai': + return new AiReporter({ write: (text: string) => stdout.write(text) }); + case 'json': + return new JsonReporter({ write: (text: string) => stdout.write(text) }); + case 'plaintext': + return new PlaintextReporter({ + write: (text: string) => stdout.write(text), + variant: isCiDetected(env) ? 'detailed' : 'concise', + color: false + }); + case 'file': + return new FileReporter(); + case 'legacy': + return undefined; + } +} + +export async function initializeRushReporterHostAsync( + options: IRushReporterHostOptions = {} +): Promise { + const env: Record = options.env ?? process.env; + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ env }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + }); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } + } + + await host.manager.initializeAsync(); + let closePromise: Promise | undefined; + return { + host, + sink: host.getSink(), + selection, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; +} diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 615aaa0e356..6e450e7aca0 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -7,9 +7,10 @@ import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; -import { _FlagFile, _RushGlobalFolder, type ILaunchOptions } from '@microsoft/rush-lib'; +import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; import { RushCommandSelector } from './RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; const MAX_INSTALL_ATTEMPTS: number = 3; @@ -26,7 +27,7 @@ export class RushVersionSelector { public async ensureRushVersionInstalledAsync( version: string, configuration: MinimalRushConfiguration | undefined, - executeOptions: ILaunchOptions + executeOptions: IRushFrontendLaunchOptions ): Promise { const isLegacyRushVersion: boolean = semver.lt(version, '4.0.0'); const expectedRushPath: string = path.join(this._rushGlobalFolder.nodeSpecificPath, `rush-${version}`); diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index bba3469421f..eda177e33c3 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -7,7 +7,7 @@ import * as rushLib from '@microsoft/rush-lib'; import { PackageJsonLookup, Import } from '@rushstack/node-core-library'; -import { RushCommandSelector } from './RushCommandSelector'; +import { launchRushFrontendAsync } from './RushFrontend'; const builtInPluginConfigurations: rushLib._IBuiltInPluginConfiguration[] = []; @@ -34,8 +34,17 @@ includePlugin('rush-serve-plugin'); includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; -RushCommandSelector.execute(currentPackageVersion, rushLib, { - isManaged: false, - alreadyReportedNodeTooNewError: false, - builtInPluginConfigurations +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { + isManaged: false, + alreadyReportedNodeTooNewError: false, + builtInPluginConfigurations + }, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(error); }); diff --git a/apps/rush/src/start.ts b/apps/rush/src/start.ts index bf8d5927230..ff4db06b442 100644 --- a/apps/rush/src/start.ts +++ b/apps/rush/src/start.ts @@ -29,9 +29,8 @@ import { EnvironmentVariableNames } from '@microsoft/rush-lib'; import type { ILaunchOptions } from '@microsoft/rush-lib'; import * as rushLib from '@microsoft/rush-lib'; -import { RushCommandSelector } from './RushCommandSelector'; -import { RushVersionSelector } from './RushVersionSelector'; import { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import { launchRushFrontendAsync } from './RushFrontend'; // Load the configuration const configuration: MinimalRushConfiguration | undefined = @@ -90,16 +89,13 @@ const terminalProvider: ITerminalProvider = new ConsoleTerminalProvider(); const launchOptions: ILaunchOptions = { isManaged, alreadyReportedNodeTooNewError, terminalProvider }; -// If we're inside a repo folder, and it's requesting a different version, then use the RushVersionManager to -// install it -if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { - const versionSelector: RushVersionSelector = new RushVersionSelector(currentPackageVersion); - versionSelector - .ensureRushVersionInstalledAsync(rushVersionToLoad, configuration, launchOptions) - .catch((error: Error) => { - console.log(Colorize.red('Error: ' + error.message)); - }); -} else { - // Otherwise invoke the rush-lib that came with this rush package - RushCommandSelector.execute(currentPackageVersion, rushLib, launchOptions); -} +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(Colorize.red(`Error: ${error.message}`)); +}); diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 391c9feeeb2..80b95dbd6aa 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -19,6 +19,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('2.5.0'); + expect(config.useRushReporter).toBe(false); }); }); @@ -31,6 +32,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); + expect(config.useRushReporter).toBe(true); }); }); }); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts new file mode 100644 index 00000000000..30b4081f405 --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,1021 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import * as rushLib from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { + ReporterHost, + ReporterManager, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink +} from '@rushstack/rush-reporter'; + +import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import { + initializeRushReporterHostAsync, + type IInitializedRushReporterHost, + type IRushReporterSelection +} from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +async function createInitializedHostAsync( + order: string[], + reason: IInitializedRushReporterHost['selection']['reason'] = 'pre-major legacy default' +): Promise { + order.push('host'); + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + let closePromise: Promise | undefined; + const hasExplicitReporter: boolean = reason === 'explicit --reporter'; + return { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reporterControlsOwnedByFrontend: hasExplicitReporter, + reporterValueFlagsToStrip: hasExplicitReporter ? ['--reporter'] : [], + reason + }, + closeAsync: (timeoutMs?: number) => { + if (!closePromise) { + order.push('close'); + closePromise = host.manager.closeAsync(timeoutMs); + } + return closePromise; + } + }; +} + +async function createEnabledHostAsync( + closeAsync?: (timeoutMs?: number) => Promise +): Promise { + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + return { + host, + sink: host.getSink(), + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: closeAsync ?? ((timeoutMs?: number) => host.manager.closeAsync(timeoutMs)) + }; +} + +async function createPhaseHangingHostAsync( + hangingPhase: 'flush' | 'close' +): Promise { + const never: Promise = new Promise(() => undefined); + const reporter: IReporter = { + name: `hang-${hangingPhase}`, + initializeAsync: async (context: IReporterContext) => { + void context; + }, + report: (event: IReporterEventEnvelope) => { + void event; + }, + flushAsync: () => (hangingPhase === 'flush' ? never : Promise.resolve()), + closeAsync: () => (hangingPhase === 'close' ? never : Promise.resolve()) + }; + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(reporter); + const host: ReporterHost = new ReporterHost({ env: {}, manager }); + await manager.initializeAsync(); + let closePromise: Promise | undefined; + return { + host, + sink: host.getSink(), + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: (timeoutMs?: number) => { + closePromise ??= manager.closeAsync(timeoutMs); + return closePromise; + } + }; +} + +interface ITestProcessLifecycle extends IRushFrontendProcessLifecycle { + beforeExitListener: (() => void) | undefined; + readonly signalListeners: Map<'SIGINT' | 'SIGTERM', () => void>; + readonly terminatedSignals: Array<'SIGINT' | 'SIGTERM'>; + readonly exitCodes: number[]; + readonly closeErrors: Error[]; +} + +function createTestProcessLifecycle(): ITestProcessLifecycle { + const lifecycle: ITestProcessLifecycle = { + beforeExitListener: undefined, + signalListeners: new Map(), + terminatedSignals: [], + exitCodes: [], + closeErrors: [], + registerBeforeExit: (listener: () => void) => { + lifecycle.beforeExitListener = listener; + return () => { + if (lifecycle.beforeExitListener === listener) { + lifecycle.beforeExitListener = undefined; + } + }; + }, + registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => { + lifecycle.signalListeners.set(signal, listener); + return () => { + if (lifecycle.signalListeners.get(signal) === listener) { + lifecycle.signalListeners.delete(signal); + } + }; + }, + terminate: (signal: 'SIGINT' | 'SIGTERM') => { + lifecycle.terminatedSignals.push(signal); + }, + setExitCode: (exitCode: number) => { + lifecycle.exitCodes.push(exitCode); + }, + reportCloseError: (error: Error) => { + lifecycle.closeErrors.push(error); + } + }; + return lifecycle; +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +describe(launchRushFrontendAsync.name, () => { + it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { + const order: string[] = []; + let receivedOptions: Record | undefined; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order, 'explicit --reporter'), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + order.push('engine'); + receivedOptions = launchOptions as unknown as Record; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(order).toEqual(['host', 'engine', 'close']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporterEventSink).toEqual( + expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink + ); + expect(receivedOptions).not.toHaveProperty('selection'); + expect(receivedOptions).not.toHaveProperty('host'); + expect(receivedOptions).not.toHaveProperty('manager'); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + const createVersionSelector: jest.Mock = jest.fn(); + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined } + }), + createVersionSelector, + processLifecycle + }) + ).rejects.toThrow(/selected Rush engine 5\.177\.0 cannot safely use --reporter=json/); + + expect(createVersionSelector).not.toHaveBeenCalled(); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + await expect(fs.promises.stat(outputPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('keeps an implicit repository opt-in on the legacy path for an incompatible engine', async () => { + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + receivedArgv = [...process.argv]; + await launchOptions.reporterCloseAsync(); + }; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + name: 'unsupported custom reporter', + reporter: 'junit', + expectedArgv: [ + 'node', + 'rush', + 'custom', + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ] + }, + { + name: 'explicit legacy reporter', + reporter: 'legacy', + expectedArgv: ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'] + } + ])('preserves the old-engine $name escape path', async ({ reporter, expectedArgv }) => { + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + receivedArgv = [...process.argv]; + await launchOptions.reporterCloseAsync(); + }; + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + 'rush', + 'custom', + '--reporter', + reporter, + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(receivedArgv).toEqual(expectedArgv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + name: 'unsupported reporter as a custom value', + reporter: 'junit', + env: {}, + expectedArguments: [ + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + expectedEnabled: false + }, + { + name: 'supported reporter as frontend ownership', + reporter: 'json', + env: {}, + expectedArguments: ['--verbose'], + expectedEnabled: true + }, + { + name: 'explicit legacy under the emergency override', + reporter: 'legacy', + env: { RUSH_REPORTER: 'legacy' }, + expectedArguments: ['--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + expectedEnabled: false + } + ])('runs the real custom command fixture with $name', async (testCase) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-command-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const reporterOutputPath: string = path.join(directory, 'reporter.jsonl'); + const outputValue: string = testCase.reporter === 'json' ? `json://${reporterOutputPath}` : 'custom.zip'; + const logLevelValue: string = testCase.reporter === 'json' ? 'debug' : 'custom'; + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = [ + 'node', + 'rush', + 'custom-output', + '--reporter', + testCase.reporter, + '--output', + outputValue + ]; + if (testCase.reporter !== 'json') { + process.argv.push('--log-level', logLevelValue); + } + process.argv.push('--verbose'); + let selection: IRushReporterSelection | undefined; + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: testCase.env, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection?.enabled).toBe(testCase.expectedEnabled); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-output-args.json'), 'utf8')) + ).toEqual(testCase.expectedArguments); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects an unsupported reporter typo when repository opt-in establishes ownership', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom-output', '--reporter=junit']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unsupported reporter "junit"'); + } finally { + process.argv = originalArgv; + } + }); + + it('runs a value-less custom reporter flag through the real frontend and parser boundary', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-reporter-flag-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'custom-reporter-flag', '--reporter']; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let selection: IRushReporterSelection | undefined; + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle + }); + + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-reporter-flag-args.json'), 'utf8')) + ).toEqual(['--reporter']); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('flushes and closes an explicit output through the real frontend boundary on success', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporterEventSink); + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('flushes an explicit output before the parser process.exit backstop', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-parser-exit-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + let outputAtExit: string | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporterEventSink); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); + Object.defineProperty(parser, '_rushOptions', { + value: { reporterCloseAsync: launchOptions.reporterCloseAsync } + }); + process.exitCode = 1; + + return new Promise((resolve: () => void) => { + jest.spyOn(process, 'exit').mockImplementation(() => { + outputAtExit = fs.readFileSync(outputPath, 'utf8'); + resolve(); + return undefined as never; + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + ( + parser as unknown as { + _reportErrorAndSetExitCode(error: Error): void; + } + )._reportErrorAndSetExitCode(new Error('parser failed')); + }); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(outputAtExit!).type).toBe('commandStarted'); + } finally { + jest.restoreAllMocks(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('preserves pass-through arguments byte-for-byte through the real frontend boundary', async () => { + const originalArgv: string[] = process.argv; + const passThroughArguments: string[] = [ + '--', + '--reporter=unknown', + '--reporter', + 'tool-reporter', + '--output=not-a-url', + '--output', + 'tool-output', + '--log-level=loud', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + process.argv = ['node', 'rush', 'build', '--reporter=json', ...passThroughArguments]; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'json', + logLevel: 'normal', + commandJson: false, + enabled: true + }); + expect(receivedArgv).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + } finally { + process.argv = originalArgv; + } + }); + + it('preserves custom value parameters when repository opt-in enables reporting', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + } finally { + process.argv = originalArgv; + } + }); + + it('closes exactly once when the engine rejects', async () => { + const closeAsync: jest.Mock, [number?]> = jest.fn(async () => undefined); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('engine rejected')), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('engine rejected'); + + expect(closeAsync).toHaveBeenCalledTimes(1); + }); + + it('closes exactly once when command selection fails', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: {} as typeof import('@microsoft/rush-lib'), + initializeReporterHostAsync: async () => initialized, + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unable to find the "Rush" entry point'); + + expect(order).toEqual(['host', 'close']); + } finally { + process.argv = originalArgv; + } + }); + + it('preserves the command failure when reporter close also fails', async () => { + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(async () => { + throw new Error('close failed'); + }); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('command failed')), + processLifecycle + }) + ).rejects.toThrow('command failed'); + + expect(processLifecycle.exitCodes).toEqual([1]); + expect(processLifecycle.closeErrors).toEqual([expect.objectContaining({ message: 'close failed' })]); + }); + + it.each(['rush', 'rushx', 'rush-pnpm'])( + 'does not install lifecycle listeners for the disabled %s path', + async (commandName) => { + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + commandName, + 'custom', + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let receivedArgv: string[] | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + commandName: commandName as 'rush' | 'rushx' | 'rush-pnpm', + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + } + ); + + it('uses a bounded close before preserving signal termination', async () => { + let resolveClose: (() => void) | undefined; + const closePromise: Promise = new Promise((resolve: () => void) => { + resolveClose = resolve; + }); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => closePromise); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledWith(2000); + expect(processLifecycle.terminatedSignals).toEqual([]); + + resolveClose!(); + await closePromise; + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.signalListeners.size).toBe(0); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + }); + + it('enforces the signal deadline when a longer close is already in flight', async () => { + jest.useFakeTimers(); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => new Promise(() => undefined)); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + void launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledWith(undefined); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + expect(closeAsync).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it.each(['flush', 'close'] as const)( + 'uses one signal deadline when the reporter %s phase hangs', + async (hangingPhase) => { + jest.useFakeTimers(); + const initialized: IInitializedRushReporterHost = await createPhaseHangingHostAsync(hangingPhase); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGINT')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGINT']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + } finally { + jest.useRealTimers(); + } + } + ); +}); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts new file mode 100644 index 00000000000..fc3e630773e --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,461 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IRushReporterOutputStream, + type IRushReporterSelection +} from '../RushReporterHost'; + +function resolve( + argv: readonly string[], + env: Record = {}, + isTTY: boolean = false, + repositoryOptIn: boolean = false, + forceLegacy: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn, + forceLegacy, + selectedRushVersion: forceLegacy ? '5.177.0' : undefined + }); +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +describe(resolveRushReporterSelection.name, () => { + it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { + for (const testCase of [ + { env: {}, isTTY: true }, + { env: {}, isTTY: false }, + { env: { CI: 'true' }, isTTY: false }, + { env: { COPILOT_CLI: '1' }, isTTY: true } + ]) { + expect(resolve(['build'], testCase.env, testCase.isTTY)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }); + } + }); + + it('requires an explicit non-legacy --reporter to opt in', () => { + expect(resolve(['build', '--reporter=json'], { CI: 'true' }, false)).toMatchObject({ + reporter: 'json', + enabled: true, + reason: 'explicit --reporter' + }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); + }); + + it('uses deterministic non-agent selection for the repository experiment', () => { + expect(resolve(['build'], {}, true, true)).toMatchObject({ + reporter: 'default', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { CI: 'true' }, true, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { COPILOT_CLI: '1' }, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build', '--quiet', '--verbose', '--debug'], {}, false, true).logLevel).toBe('debug'); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve( + ['build', '--reporter=plaintext', '--log-level=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + + it('preserves custom value parameters when the repository experiment selects the reporter implicitly', () => { + expect( + resolve( + ['custom', '--output', 'artifact.zip', '--log-level', 'custom-level', '--verbose'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + }); + + it('does not consume rush-pnpm or rushx reporter arguments', () => { + expect( + resolveRushReporterSelection({ + argv: ['install', '--reporter=append-only'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rush-pnpm' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + expect( + resolveRushReporterSelection({ + argv: ['build', '--reporter=custom-script-value'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rushx' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + }); + + it('keeps RUSH_REPORTER=legacy as an emergency override', () => { + expect( + resolve( + ['build', '--reporter=json', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'RUSH_REPORTER=legacy' + }); + + const legacySelection: IRushReporterSelection = resolve( + ['custom', '--reporter=legacy', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + { RUSH_REPORTER: 'legacy' } + ); + expect(legacySelection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterValueFlagsToStrip: ['--reporter'], + reason: 'RUSH_REPORTER=legacy' + }); + expect( + stripReporterValueControls( + [ + 'node', + 'rush', + 'custom', + '--reporter=legacy', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + new Set(legacySelection.reporterValueFlagsToStrip) + ) + ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); + }); + + it('removes reporter-only value controls before invoking a legacy engine', () => { + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'list', + '--json', + '--reporter=json', + '--output', + 'file://./rush.log', + '--log-level=debug', + '--quiet' + ]) + ).toEqual(['node', 'rush', 'list', '--json', '--quiet']); + }); + + it('preserves every argument at and after the pass-through separator', () => { + const passThroughArguments: string[] = [ + '--', + '--reporter=tool-reporter', + '--reporter', + 'tool-reporter', + '--output=tool-output', + '--output', + 'tool-output', + '--log-level=tool-level', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'build', + '--reporter=json', + '--output', + 'json://./events.jsonl', + '--log-level=debug', + ...passThroughArguments + ]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + expect( + stripReporterValueControls(['node', 'rush', 'build', '--reporter', ...passThroughArguments]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + }); + + it('ignores reporter controls and aliases after the pass-through separator', () => { + expect( + resolve([ + 'build', + '--', + '--reporter=unknown', + '--output=not-a-url', + '--log-level=loud', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary' + ]) + ).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reason: 'pre-major legacy default' + }); + }); + + it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { + expect( + resolve(['build', '--reporter=plaintext', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel + ).toBe('verbose'); + expect(resolve(['build', '--reporter=plaintext'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe('debug'); + expect(() => resolve(['build', '--reporter=plaintext', '--quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + }); + + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter'] + }); + }); + + it('ignores reporter environment selection before the gate and preserves custom value controls', () => { + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); + expect(resolve(['custom', '--reporter=junit'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(() => resolve(['custom', '--reporter=junit'], {}, false, true)).toThrow( + /Unsupported reporter "junit"/ + ); + expect(() => resolve(['custom', '--reporter=junit', '--output=json://./events.jsonl'])).toThrow( + /Unsupported reporter "junit"/ + ); + expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); + expect(resolve(['custom', '--output=json://events.jsonl', '--log-level=custom'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + }); + + it('probes value-less custom reporter flags without claiming ownership', () => { + expect(resolve(['custom', '--reporter'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['custom', '--reporter', '--verbose'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(() => resolve(['custom', '--reporter'], {}, false, true)).toThrow(/--reporter requires a value/); + expect(() => resolve(['custom', '--reporter', '--output=json://./events.jsonl'])).toThrow( + /--reporter requires a value/ + ); + expect(() => resolve(['custom', '--reporter=json', '--reporter'])).toThrow(/--reporter requires a value/); + }); + + it('rejects explicit non-legacy reporters for incompatible selected engines', () => { + expect(() => resolve(['build', '--reporter=json'], {}, false, true, true)).toThrow( + /selected Rush engine 5\.177\.0 cannot safely use --reporter=json/ + ); + expect(resolve(['custom', '--reporter=junit', '--verbose'], {}, false, false, true)).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }); + }); + + it('rejects an interactive reporter on non-TTY output', () => { + expect(() => resolve(['build', '--reporter=default'], {}, false)).toThrow(/requires an interactive TTY/); + expect(resolve(['build', '--reporter=default'], {}, true).reporter).toBe('default'); + }); + + it('parses output targets and preserves command-specific --json independently', () => { + const selection: IRushReporterSelection = resolve( + [ + 'list', + '--json', + '--reporter=json', + '--output=file://./rush.log?logLevel=debug', + '--output=json://./events.jsonl' + ], + {}, + false + ); + + expect(selection.commandJson).toBe(true); + expect(selection.reporter).toBe('json'); + expect(selection.outputs).toEqual([ + { + reporter: 'file', + target: path.resolve('/repo', 'rush.log'), + params: { logLevel: 'debug' } + }, + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl'), + params: {} + } + ]); + }); + + it('surfaces unsupported and incomplete controls with actionable errors', () => { + expect(() => resolve(['build', '--reporter=json', '--reporter=ai'])).toThrow( + /may be specified only once/ + ); + expect(() => resolve(['build', '--reporter=json', '--log-level=quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + expect(() => resolve(['build', '--reporter=json', '--output=plaintext://./output.txt'])).toThrow( + /supports file:\/\/ and json:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { + let output: string = ''; + const stdout: IRushReporterOutputStream = { + isTTY: false, + write: (text: string) => { + output += text; + } + }; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env: { CI: 'true', COPILOT_CLI: '1' }, + stdout, + includeDefaultFileReporter: false + }); + + const sink: IReporterEventSink = initialized.sink; + emitCommandStarted(sink); + await initialized.closeAsync(); + + expect(initialized.selection.enabled).toBe(false); + expect(output).toBe(''); + }); + + it('initializes the explicitly selected reporter and output destinations', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(initialized.sink); + const firstClose: Promise = initialized.closeAsync(); + expect(initialized.closeAsync()).toBe(firstClose); + await firstClose; + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json new file mode 100644 index 00000000000..596ca68ca76 --- /dev/null +++ b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "useRushReporter": true +} diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json new file mode 100644 index 00000000000..919daad035d --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add pre-major frontend reporter controls with legacy command compatibility, selected-engine gating, and deterministic reporter finalization.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/src/exit/CommandJson.ts b/libraries/reporter/src/exit/CommandJson.ts index 2e3d14cbb94..83f4cd715d0 100644 --- a/libraries/reporter/src/exit/CommandJson.ts +++ b/libraries/reporter/src/exit/CommandJson.ts @@ -37,6 +37,9 @@ export function separateJsonControls(argv: readonly string[]): IJsonControls { for (let index: number = 0; index < argv.length; index++) { const arg: string = argv[index]; + if (arg === '--') { + break; + } if (arg === '--json') { commandJson = true; } else if (arg === '--reporter=json') { diff --git a/libraries/reporter/src/test/ExitStatus.test.ts b/libraries/reporter/src/test/ExitStatus.test.ts index d8424effed1..ffd90440418 100644 --- a/libraries/reporter/src/test/ExitStatus.test.ts +++ b/libraries/reporter/src/test/ExitStatus.test.ts @@ -149,4 +149,13 @@ describe('separateJsonControls', () => { reporterJson: false }); }); + + it('stops scanning at the pass-through separator', () => { + expect( + separateJsonControls(['build', '--json', '--', '--json', '--reporter=json', '--reporter', 'json']) + ).toEqual({ + commandJson: true, + reporterJson: false + }); + }); }); diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index 64e06354047..a51af8b0930 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -17,6 +17,10 @@ import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoade import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; +interface IRushFrontendLaunchOptions extends ILaunchOptions { + reporterCloseAsync?: () => Promise; +} + /** * Options to pass to the rush "launch" functions. * @@ -78,6 +82,7 @@ export class Rush { */ public static launch(launcherVersion: string, options: ILaunchOptions): void { options = _normalizeLaunchOptions(options); + const frontendOptions: IRushFrontendLaunchOptions = options; if (!RushCommandLineParser.shouldRestrictConsoleOutput()) { RushStartupBanner.logBanner(Rush.version, options.isManaged); @@ -92,7 +97,8 @@ export class Rush { _assignRushInvokedFolder(); const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, - builtInPluginConfigurations: options.builtInPluginConfigurations + builtInPluginConfigurations: options.builtInPluginConfigurations, + reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise // eslint-disable-next-line no-console diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 47f2b3a640b..c293ca5fa0f 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporterCloseAsync?: () => Promise; } export class RushCommandLineParser extends CommandLineParser { @@ -216,6 +217,9 @@ export class RushCommandLineParser extends CommandLineParser { for (let i: number = 2; i < process.argv.length; i++) { const arg: string = process.argv[i]; + if (arg === '--') { + break; + } if (arg === '-q' || arg === '--quiet' || arg === '--json') { return true; } @@ -235,14 +239,23 @@ export class RushCommandLineParser extends CommandLineParser { public override async executeAsync(args?: string[]): Promise { // debugParameter will be correctly parsed during super.executeAsync(), so manually parse here. + const passThroughSeparatorIndex: number = process.argv.indexOf('--', 2); + const rushArgv: string[] = + passThroughSeparatorIndex < 0 + ? process.argv.slice(2) + : process.argv.slice(2, passThroughSeparatorIndex); this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = - process.argv.indexOf('--debug') >= 0; + rushArgv.includes('--debug') || rushArgv.includes('-d'); - await measureAsyncFn('rush:initializeUnassociatedPlugins', () => - this.pluginManager.tryInitializeUnassociatedPluginsAsync() - ); + try { + await measureAsyncFn('rush:initializeUnassociatedPlugins', () => + this.pluginManager.tryInitializeUnassociatedPluginsAsync() + ); - return await super.executeAsync(args); + return await super.executeAsync(args); + } finally { + await this._closeReporterAsync(); + } } protected override async onExecuteAsync(): Promise { @@ -309,7 +322,8 @@ export class RushCommandLineParser extends CommandLineParser { return { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, - builtInPluginConfigurations: options.builtInPluginConfigurations || [] + builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporterCloseAsync: options.reporterCloseAsync }; } @@ -548,10 +562,31 @@ export class RushCommandLineParser extends CommandLineParser { } }; - if (this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed()) { - this.telemetry.ensureFlushedAsync().then(handleExit).catch(handleExit); + const telemetryFlushAsync: Promise | undefined = + this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed() + ? this.telemetry.ensureFlushedAsync() + : undefined; + + if (this._rushOptions.reporterCloseAsync || telemetryFlushAsync) { + const pendingFlushes: Promise[] = []; + if (this._rushOptions.reporterCloseAsync) { + pendingFlushes.push(this._closeReporterAsync()); + } + if (telemetryFlushAsync) { + pendingFlushes.push(telemetryFlushAsync); + } + void Promise.allSettled(pendingFlushes).then(handleExit); } else { handleExit(); } } + + private async _closeReporterAsync(): Promise { + try { + await this._rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + } + } } diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index dcdbca339ff..64d47c1cfdf 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -114,6 +114,36 @@ describe('RushCommandLineParser', () => { }); }); + describe("'custom-output' action", () => { + it('preserves custom parameters that overlap reporter controls', async () => { + const { parser, repoPath } = await getCommandLineParserInstanceAsync( + 'basicAndRunBuildActionRepo', + 'custom-output' + ); + process.argv.push( + '--reporter', + 'junit', + '--output', + 'custom-artifact.zip', + '--log-level', + 'custom-level', + '--verbose' + ); + + await expect(parser.executeAsync()).resolves.toEqual(true); + + expect(JsonFile.load(`${repoPath}/custom-output-args.json`)).toEqual([ + '--reporter', + 'junit', + '--output', + 'custom-artifact.zip', + '--log-level', + 'custom-level', + '--verbose' + ]); + }); + }); + describe("'rebuild' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunRebuildActionRepo'; @@ -140,6 +170,20 @@ describe('RushCommandLineParser', () => { cwdOptionEquals(secondSpawn, `${repoPath}/b`); }); }); + + describe("'custom-reporter-flag' action", () => { + it('preserves a value-less custom reporter flag', async () => { + const { parser, repoPath } = await getCommandLineParserInstanceAsync( + 'basicAndRunRebuildActionRepo', + 'custom-reporter-flag' + ); + process.argv.push('--reporter'); + + await expect(parser.executeAsync()).resolves.toEqual(true); + + expect(JsonFile.load(`${repoPath}/custom-reporter-flag-args.json`)).toEqual(['--reporter']); + }); + }); }); describe("in repo with 'rebuild' command overridden", () => { diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts new file mode 100644 index 00000000000..b8113aad056 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushCommandLineParser } from '../RushCommandLineParser'; +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; + +describe('RushCommandLineParser reporter close', () => { + const originalExitCode: string | number | null | undefined = process.exitCode; + const originalArgv: string[] = process.argv; + + afterEach(() => { + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + }); + + it('does not treat pass-through quiet, debug, or json arguments as Rush controls', async () => { + process.argv = ['node', 'rush', 'build', '--', '--quiet', '-q', '--debug', '-d', '--json']; + + expect(RushCommandLineParser.shouldRestrictConsoleOutput()).toBe(false); + + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => undefined + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + await parser.executeAsync(['not-a-rush-command']); + + const terminalProvider: { debugEnabled: boolean; verboseEnabled: boolean } = ( + parser as unknown as { + _terminalProvider: { debugEnabled: boolean; verboseEnabled: boolean }; + } + )._terminalProvider; + expect(terminalProvider.debugEnabled).toBe(false); + expect(terminalProvider.verboseEnabled).toBe(false); + }); + + it('closes after command-line parser rejection', async () => { + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(parser.executeAsync(['not-a-rush-command'])).resolves.toBe(false); + + expect(closeAsync).toHaveBeenCalledTimes(1); + }); + + it.each(['build', 'rebuild', 'check'])('accepts post-command --verbose for %s', async (commandName) => { + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => undefined + }); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(parser.executeAsync([commandName, '--verbose', '--help'])).resolves.toBe(true); + }); + + it('waits for reporter close before an explicit parser exit', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); + Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + process.exitCode = 1; + + const reportErrorAndSetExitCode: (error: Error) => void = ( + parser as unknown as { + _reportErrorAndSetExitCode(error: Error): void; + } + )._reportErrorAndSetExitCode.bind(parser); + reportErrorAndSetExitCode(new Error('parser failed')); + + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + + resolveClose!(); + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('reports close failure without rejecting from parser finalization', async () => { + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_rushOptions', { + value: { reporterCloseAsync: async () => Promise.reject(new Error('close failed')) } + }); + const errorSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + process.exitCode = 0; + + const closeReporterAsync: () => Promise = ( + parser as unknown as { + _closeReporterAsync(): Promise; + } + )._closeReporterAsync.bind(parser); + await expect(closeReporterAsync()).resolves.toBeUndefined(); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); + }); +}); diff --git a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json new file mode 100644 index 00000000000..c7d4e88c76b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,39 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-output", + "summary": "Exercises custom parameters that overlap reporter controls.", + "shellCommand": "node custom-output.js" + } + ], + "parameters": [ + { + "parameterKind": "string", + "longName": "--reporter", + "argumentName": "REPORTER", + "description": "Custom reporter value.", + "associatedCommands": ["custom-output"] + }, + { + "parameterKind": "string", + "longName": "--output", + "argumentName": "OUTPUT", + "description": "Custom output value.", + "associatedCommands": ["custom-output"] + }, + { + "parameterKind": "string", + "longName": "--log-level", + "argumentName": "LEVEL", + "description": "Custom log level.", + "associatedCommands": ["custom-output"] + }, + { + "parameterKind": "flag", + "longName": "--verbose", + "description": "Custom verbose flag.", + "associatedCommands": ["custom-output"] + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js new file mode 100644 index 00000000000..378b29c86a2 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const fs = require('node:fs'); +const path = require('node:path'); + +fs.writeFileSync( + path.join(process.cwd(), 'custom-output-args.json'), + `${JSON.stringify(process.argv.slice(2), undefined, 2)}\n` +); diff --git a/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json new file mode 100644 index 00000000000..dbd2433e3db --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,18 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-reporter-flag", + "summary": "Exercises a value-less custom reporter flag.", + "shellCommand": "node custom-reporter-flag.js" + } + ], + "parameters": [ + { + "parameterKind": "flag", + "longName": "--reporter", + "description": "Custom reporter flag.", + "associatedCommands": ["custom-reporter-flag"] + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js new file mode 100644 index 00000000000..0e0f0a9db49 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const fs = require('node:fs'); +const path = require('node:path'); + +fs.writeFileSync( + path.join(process.cwd(), 'custom-reporter-flag-args.json'), + `${JSON.stringify(process.argv.slice(2), undefined, 2)}\n` +);