diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 46d58acb45..1ad16898ae 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -3,10 +3,14 @@ import * as path from 'node:path'; -import { FileSystem, JsonFile } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; import { RushConfiguration } from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { isSupportedReporterName, type ReporterName } from '@rushstack/rush-reporter'; + +import { getRushPreviewVersion } from './RushPreviewVersion'; interface IMinimalRushConfigurationJson { rushMinimumVersion: string; @@ -52,16 +56,35 @@ export class MinimalRushConfiguration { } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { + const showVerbose: boolean = !RushCommandLineParser.shouldRestrictConsoleOutput(); const rushJsonLocation: string | undefined = RushConfiguration.tryFindRushJsonLocation({ - showVerbose: !RushCommandLineParser.shouldRestrictConsoleOutput() + showVerbose: false }); if (rushJsonLocation) { const minimalRushConfigurationJson: IMinimalRushConfigurationJson | undefined = _loadConfigurationJson(rushJsonLocation); + const explicitReporter: ReporterName | undefined = _getExplicitReporter(process.argv.slice(2)); + const legacyFallbackRequested: boolean = + explicitReporter === 'legacy' || + process.env.RUSH_REPORTER?.trim().toLowerCase() === 'legacy' || + _hasHelpControl(process.argv.slice(2)); + let configuration: MinimalRushConfiguration | undefined; + let legacyPresentation: boolean = legacyFallbackRequested || explicitReporter === undefined; if (minimalRushConfigurationJson) { - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + configuration = new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; + const effectiveRushVersion: string = getRushPreviewVersion() ?? configuration.rushVersion; + legacyPresentation = + legacyFallbackRequested || + effectiveRushVersion !== currentPackageVersion || + (!configuration.useRushReporter && explicitReporter === undefined); } - return undefined; + if (showVerbose && legacyPresentation) { + // Preserve discovery even when the full engine must report a configuration load error. + console.log('Found configuration in ' + rushJsonLocation); + console.log(''); + } + return configuration; } else { return undefined; } @@ -94,6 +117,53 @@ export class MinimalRushConfiguration { public get useRushReporter(): boolean { return this.#useRushReporter; } + + /** + * The repository's common temp folder, used for invocation-scoped reporter logs. + */ + public get commonTempFolder(): string { + return ( + EnvironmentConfiguration._getRushTempFolderOverride(process.env) ?? + path.resolve(this.#commonRushConfigFolder, '..', '..', 'temp') + ); + } +} + +function _getExplicitReporter(argv: readonly string[]): ReporterName | undefined { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + let value: string | undefined; + if (argument === '--reporter') { + const nextArgument: string | undefined = argv[index + 1]; + if (!nextArgument || nextArgument.startsWith('-')) { + continue; + } + value = nextArgument; + index++; + } else if (argument.startsWith('--reporter=')) { + value = argument.slice('--reporter='.length); + } + if (value !== undefined) { + const normalizedValue: string = value.trim().toLowerCase(); + return isSupportedReporterName(normalizedValue) ? normalizedValue : undefined; + } + } + return undefined; +} + +function _hasHelpControl(argv: readonly string[]): boolean { + for (const argument of argv) { + if (argument === '--') { + return false; + } + if (argument === '--help' || argument === '-h') { + return true; + } + } + return false; } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 9fe60216ba..75de36faf7 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'; import type { ILaunchOptions } from '@microsoft/rush-lib'; -import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; +import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -139,10 +139,14 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr processLifecycle = createProcessLifecycle() } = options; + const engineArgv: string[] = stripReporterValueControls(process.argv.slice(2)); + const actionName: string | undefined = engineArgv.find((argument: string) => !argument.startsWith('-')); const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ repositoryOptIn: configuration?.useRushReporter, forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, - selectedRushVersion: rushVersionToLoad + selectedRushVersion: rushVersionToLoad, + commonTempFolder: actionName === 'purge' ? undefined : configuration?.commonTempFolder, + actionName }); const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) @@ -154,10 +158,27 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr new Set(reporterHost.selection.reporterValueFlagsToStrip), new Set(reporterHost.selection.reporterFlagsToStrip) ); + delete process.env.RUSH_REPORTER; + delete process.env.RUSH_LOG_LEVEL; } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); + if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { + reporterHost.sink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId, + source: { packageName: '@microsoft/rush', packageVersion: currentPackageVersion }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: reporterHost.logArtifact.path, + format: 'plaintext', + complete: false + } + }); + } const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporter: { diff --git a/apps/rush/src/RushPreviewVersion.ts b/apps/rush/src/RushPreviewVersion.ts new file mode 100644 index 0000000000..87bae4dc56 --- /dev/null +++ b/apps/rush/src/RushPreviewVersion.ts @@ -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. + +import { EnvironmentVariableNames } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; + +export function getRushPreviewVersion( + env: Record = process.env +): string | undefined { + return env[EnvironmentVariableNames.RUSH_PREVIEW_VERSION] || undefined; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 45925e2537..954e87e786 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -20,12 +20,15 @@ import { shouldRenderAtLogLevel, type IReporter, type IReporterContext, + type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventSink, + type IFileReporterArtifact, type IReporterOutputTarget, type ReporterEventType, type ReporterLogLevel, - type ReporterName + type ReporterName, + type ReporterManager } from '@rushstack/rush-reporter'; import { @@ -45,11 +48,14 @@ export interface IRushReporterHostOptions { readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; readonly stderr?: IRushReporterOutputStream; + readonly commonTempFolder?: string; + readonly actionName?: string; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; + readonly manager?: ReporterManager; } export interface IRushReporterSelection { @@ -62,23 +68,22 @@ export interface IRushReporterSelection { readonly reporterValueFlagsToStrip: readonly string[]; readonly reporterFlagsToStrip?: readonly string[]; readonly reason: - | 'explicit --reporter' - | 'repository experiment' - | 'RUSH_REPORTER=legacy' - | 'pre-major legacy default'; + 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; } export interface IInitializedRushReporterHost { readonly host: ReporterHost; readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; + readonly logArtifact: IFileReporterArtifact | undefined; 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']; -const DEFERRED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ +const REPORTER_OUTPUT_VALUE_FLAGS: readonly string[] = ['--output', '--log-level']; +const GROUPED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ 'operationRegistered', 'operationStatusChanged', 'operationStreamClosed', @@ -100,43 +105,16 @@ class LogLevelReporter implements IReporter { private readonly _reporter: IReporter; private readonly _logLevel: ReporterLogLevel; + private readonly _preserveOperationStream: boolean; - public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + public constructor( + reporter: IReporter, + logLevel: ReporterLogLevel, + preserveOperationStream: boolean = false + ) { 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(); - } -} - -/** - * Keeps operation presentation on the legacy collator until R5B transfers terminal ownership. - * Output without an operation scope remains owned by the primary reporter. - */ -class DeferredOperationPresentationReporter implements IReporter { - public readonly name: string; - - private readonly _reporter: IReporter; - - public constructor(reporter: IReporter) { - this._reporter = reporter; + this._preserveOperationStream = preserveOperationStream; this.name = reporter.name; } @@ -146,8 +124,11 @@ class DeferredOperationPresentationReporter implements IReporter { public report(event: IReporterEventEnvelope): void { if ( - !DEFERRED_OPERATION_EVENT_TYPES.has(event.type) || - (event.type === 'externalOutput' && event.scope?.operationId === undefined) + shouldRenderAtLogLevel(this._logLevel, event) || + event.type === 'artifactAvailable' || + (this._preserveOperationStream && + GROUPED_OPERATION_EVENT_TYPES.has(event.type) && + !(this._logLevel === 'quiet' && event.type === 'externalOutput')) ) { this._reporter.report(event); } @@ -226,6 +207,112 @@ class ExplicitOutputReporter implements IReporter { } } +class FilePathReporter implements IReporter { + public readonly name: string = 'file-path'; + + private readonly _write: (text: string) => unknown; + private _path: string | undefined; + private _written: boolean = false; + + public constructor(write: (text: string) => unknown) { + this._write = write; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string } = event.payload as { + role?: string; + path?: string; + }; + if (payload.role === 'log') { + this._path = payload.path; + } + } else if ((event.type === 'commandResult' || event.type === 'sessionCompleted') && this._path) { + this._writePathOnce(); + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + this._writePathOnce(); + } + + private _writePathOnce(): void { + if (!this._written && this._path) { + this._written = true; + this._write(`Rush full log: ${this._path}\n`); + } + } +} + +class ArtifactCompletionReporterSink implements IReporterEventSink { + private readonly _host: ReporterHost; + private readonly _fullDetailReporter: FileReporter; + private _lastComplete: boolean | undefined; + private _artifactContext: IReporterEmitEventInput | undefined; + + public constructor(host: ReporterHost, fullDetailReporter: FileReporter) { + this._host = host; + this._fullDetailReporter = fullDetailReporter; + } + + public emit(event: IReporterEmitEventInput): string { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string; complete?: boolean } = event.payload as { + role?: string; + path?: string; + complete?: boolean; + }; + if (payload.role === 'log' && typeof payload.complete === 'boolean') { + this._lastComplete = payload.complete; + this._artifactContext = event; + } + } + return this._host.manager.emit(event); + } + + public publishIfChanged( + context: IReporterEmitEventInput | undefined = this._artifactContext + ): void { + if (!context) { + return; + } + const artifact: IFileReporterArtifact = this._fullDetailReporter.getArtifact(); + if (!artifact.path || artifact.complete === this._lastComplete) { + return; + } + const complete: boolean = artifact.complete; + const payload: Readonly<{ + role: 'log'; + path: string; + format: 'plaintext'; + complete: boolean; + }> = Object.freeze({ + role: 'log' as const, + path: artifact.path, + format: 'plaintext' as const, + complete + }); + this._host.manager.emit({ + protocolVersion: context.protocolVersion, + sessionId: context.sessionId, + source: context.source, + scope: context.scope, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload + }); + this._lastComplete = complete; + } +} + function isSeparatedControlValue(value: string | undefined): value is string { return value !== undefined && value.length > 0 && !value.startsWith('-'); } @@ -374,6 +461,67 @@ function hasReporterOutputControl(argv: readonly string[]): boolean { return false; } +function hasHelpControl(argv: readonly string[]): boolean { + for (const argument of argv) { + if (argument === '--') { + return false; + } + if (argument === '--help' || argument === '-h') { + return true; + } + } + return false; +} + +function getImplicitHelpValueFlagsToStrip( + argv: readonly string[], + ownership: IReporterCommandLineOwnership, + actionName: string | undefined +): readonly string[] { + if (actionName !== undefined && !ownership.known) { + return []; + } + const commandOwnedFlags: ReadonlySet = ownership.parameters; + const outputs: (string | undefined)[] = []; + const logLevels: (string | undefined)[] = []; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + const equalsIndex: number = argument.indexOf('='); + const flag: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); + if (commandOwnedFlags.has(flag)) { + continue; + } + const values: (string | undefined)[] | undefined = + flag === '--output' ? outputs : flag === '--log-level' ? logLevels : undefined; + if (values) { + const nextArgument: string | undefined = argv[index + 1]; + values.push( + equalsIndex >= 0 + ? argument.slice(equalsIndex + 1) + : nextArgument && !nextArgument.startsWith('-') + ? argv[++index] + : undefined + ); + } + } + + // Help must tolerate command-owned flags without parsing their values as reporter controls. + const logLevelsAreOwned: boolean = logLevels.every( + (value: string | undefined) => value !== undefined && isSupportedLogLevel(value) + ); + const isReporterOutput = (value: string | undefined): boolean => + value !== undefined && /^(?:file|json):\/\//.test(value); + if (outputs.some(isReporterOutput)) { + return outputs.every(isReporterOutput) && logLevelsAreOwned + ? REPORTER_OUTPUT_VALUE_FLAGS.filter((flag) => !commandOwnedFlags.has(flag)) + : []; + } + return logLevels.length > 0 && logLevelsAreOwned ? ['--log-level'] : []; +} + function resolveLogLevel( controls: IParsedReporterControls, env: Record, @@ -425,6 +573,19 @@ function resolveLogLevel( } const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + const environmentQuiet: boolean = + includeEnvironment && (env.RUSH_QUIET_MODE === '1' || env.RUSH_QUIET_MODE?.toLowerCase() === 'true'); + if (environmentQuiet && environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (normalizedLogLevel !== 'quiet') { + throw new Error( + 'RUSH_QUIET_MODE contradicts RUSH_LOG_LEVEL. Remove one of these environment controls.' + ); + } + } + if (environmentQuiet) { + return 'quiet'; + } if (environmentLogLevel) { const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); if (!isSupportedLogLevel(normalizedLogLevel)) { @@ -496,6 +657,11 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const cwd: string = options.cwd ?? process.cwd(); const commandJson: boolean = separateJsonControls(argv).commandJson; + const separator: number = argv.indexOf('--'); + const actionName: string | undefined = stripReporterValueControls( + separator < 0 ? argv : argv.slice(0, separator) + ).find((argument) => !argument.startsWith('-')); + let commandOwnership: IReporterCommandLineOwnership | undefined; const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); if (isLegacyEmergencyFallbackRequested(env)) { @@ -512,7 +678,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson, enabled: false, - reporterControlsOwnedByFrontend: reporterValueFlagsToStrip.length > 0, + reporterControlsOwnedByFrontend: true, reporterValueFlagsToStrip, reason: 'RUSH_REPORTER=legacy' }; @@ -560,6 +726,36 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + if (hasHelpControl(argv)) { + const reporterValueFlagsToStrip: readonly string[] = + requestedReporter !== undefined + ? requestedReporter === 'legacy' + ? REPORTER_SELECTION_FLAG + : ALL_REPORTER_VALUE_FLAGS + : options.repositoryOptIn + ? getImplicitHelpValueFlagsToStrip(argv, getCommandOwnership(), actionName) + : []; + const reporterFlagsToStrip: readonly string[] = ( + requestedReporter === undefined ? options.repositoryOptIn === true : requestedReporter !== 'legacy' + ) + ? getFlagsToStrip(selectionControls) + : []; + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: + reporterValueFlagsToStrip.length > 0 || + reporterFlagsToStrip.length > 0 || + (options.repositoryOptIn === true && env.RUSH_LOG_LEVEL !== undefined), + reporterValueFlagsToStrip, + ...(reporterFlagsToStrip.length > 0 ? { reporterFlagsToStrip } : {}), + 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') { @@ -571,13 +767,8 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return 'rush'; } - let commandOwnership: IReporterCommandLineOwnership | undefined; function getCommandOwnership(): IReporterCommandLineOwnership { if (!commandOwnership) { - const separator: number = argv.indexOf('--'); - const actionName: string | undefined = stripReporterValueControls( - separator < 0 ? argv : argv.slice(0, separator) - ).find((argument) => !argument.startsWith('-')); commandOwnership = getReporterCommandLineOwnership(actionName, cwd); } return commandOwnership; @@ -614,13 +805,15 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = if (controls.logLevels.length > 0) reporterValueFlagsToStrip.push('--log-level'); const reporterFlagsToStrip: readonly string[] = getFlagsToStrip(controls); return { - reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + reporter: commandJson ? 'file' : isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', logLevel: resolveLogLevel(controls, env, true, true), outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, reporterControlsOwnedByFrontend: - reporterValueFlagsToStrip.length > 0 || reporterFlagsToStrip.length > 0, + reporterValueFlagsToStrip.length > 0 || + reporterFlagsToStrip.length > 0 || + env.RUSH_LOG_LEVEL !== undefined, reporterValueFlagsToStrip, ...(reporterFlagsToStrip.length > 0 ? { reporterFlagsToStrip } : {}), reason: 'repository experiment' @@ -651,6 +844,13 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + if (commandJson && requestedReporter !== 'file') { + throw new Error( + `The command-specific --json output owns stdout and cannot be combined with --reporter=${requestedReporter}. ` + + 'Use --reporter=file or omit --reporter.' + ); + } + const controls: IParsedReporterControls = parseReporterControls(argv, true); validateReporterControlMultiplicity(controls, true); const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; @@ -682,8 +882,12 @@ function createPrimaryReporter( case 'default': return new DefaultInteractiveReporter({ terminal: { - columns: stdout.columns ?? 80, - isTTY: stdout.isTTY === true, + get columns() { + return stdout.columns && stdout.columns > 0 ? stdout.columns : 80; + }, + get isTTY() { + return stdout.isTTY === true; + }, write: (text: string) => { stdout.write(text); } @@ -697,11 +901,12 @@ function createPrimaryReporter( case 'plaintext': return new PlaintextReporter({ write: (text: string) => stdout.write(text), - variant: isCiDetected(env) ? 'detailed' : 'concise', - color: false + variant: selection.reason === 'explicit --reporter' || isCiDetected(env) ? 'detailed' : 'concise', + color: false, + logLevel: selection.logLevel }); case 'file': - return new FileReporter(); + return undefined; case 'legacy': return undefined; } @@ -714,29 +919,33 @@ export async function initializeRushReporterHostAsync( const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; const stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); - const host: ReporterHost = new ReporterHost({ env }); + const host: ReporterHost = new ReporterHost({ env, manager: options.manager }); + let fullDetailReporter: FileReporter | undefined; if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); - if (primaryReporter) { - const presentationReporter: IReporter = - selection.reporter === 'file' - ? primaryReporter - : new DeferredOperationPresentationReporter(primaryReporter); - host.manager.addReporter(new LogLevelReporter(presentationReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + + if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { + fullDetailReporter = new FileReporter({ + commonTempFolder: options.commonTempFolder, + actionName: options.actionName }); + host.manager.addReporter(fullDetailReporter, { destination: 'file:auto' }); } - 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' }); + if (primaryReporter) { + host.manager.addReporter( + new LogLevelReporter(primaryReporter, selection.logLevel, selection.reporter === 'plaintext'), + { + destination: 'stdout' + } + ); + } + + if (selection.reporter === 'file') { + host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { + destination: 'stderr' + }); } for (const output of selection.outputs) { @@ -759,13 +968,24 @@ export async function initializeRushReporterHostAsync( } await host.manager.initializeAsync(); + const artifactCompletionSink: ArtifactCompletionReporterSink | undefined = fullDetailReporter + ? new ArtifactCompletionReporterSink(host, fullDetailReporter) + : undefined; let closePromise: Promise | undefined; return { host, - sink: host.getSink(), + sink: artifactCompletionSink ?? host.getSink(), selection, + logArtifact: fullDetailReporter?.getArtifact(), closeAsync: (timeoutMs?: number) => { - closePromise ??= host.manager.closeAsync(timeoutMs); + closePromise ??= (async () => { + const fullyFlushed: boolean = await host.manager._flushAndConfirmAsync(timeoutMs); + if (fullyFlushed) { + await fullDetailReporter?.closeAsync(); + artifactCompletionSink?.publishIfChanged(); + } + await host.manager.closeAsync(timeoutMs); + })(); return closePromise; } }; diff --git a/apps/rush/src/start.ts b/apps/rush/src/start.ts index ff4db06b44..01e8d90f28 100644 --- a/apps/rush/src/start.ts +++ b/apps/rush/src/start.ts @@ -31,6 +31,7 @@ import * as rushLib from '@microsoft/rush-lib'; import { MinimalRushConfiguration } from './MinimalRushConfiguration'; import { launchRushFrontendAsync } from './RushFrontend'; +import { getRushPreviewVersion } from './RushPreviewVersion'; // Load the configuration const configuration: MinimalRushConfiguration | undefined = @@ -40,7 +41,7 @@ const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dir let rushVersionToLoad: string | undefined = undefined; -const previewVersion: string | undefined = process.env[EnvironmentVariableNames.RUSH_PREVIEW_VERSION]; +const previewVersion: string | undefined = getRushPreviewVersion(); if (previewVersion) { if (!semver.valid(previewVersion, false)) { diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 80b95dbd6a..861304b929 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -3,11 +3,36 @@ import * as path from 'node:path'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; + import { MinimalRushConfiguration } from '../MinimalRushConfiguration'; describe(MinimalRushConfiguration.name, () => { + const originalArgv: string[] = process.argv; + const originalRushTempFolder: string | undefined = process.env.RUSH_TEMP_FOLDER; + const originalRushPreviewVersion: string | undefined = process.env.RUSH_PREVIEW_VERSION; + const originalRushReporter: string | undefined = process.env.RUSH_REPORTER; + afterEach(() => { jest.restoreAllMocks(); + process.argv = originalArgv; + if (originalRushTempFolder === undefined) { + delete process.env.RUSH_TEMP_FOLDER; + } else { + process.env.RUSH_TEMP_FOLDER = originalRushTempFolder; + } + if (originalRushPreviewVersion === undefined) { + delete process.env.RUSH_PREVIEW_VERSION; + } else { + process.env.RUSH_PREVIEW_VERSION = originalRushPreviewVersion; + } + if (originalRushReporter === undefined) { + delete process.env.RUSH_REPORTER; + } else { + process.env.RUSH_REPORTER = originalRushReporter; + } + EnvironmentConfiguration.reset(); }); describe('legacy rush config', () => { @@ -33,6 +58,194 @@ describe(MinimalRushConfiguration.name, () => { MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); expect(config.useRushReporter).toBe(true); + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'common', 'temp')); + }); + + it('uses the normalized RUSH_TEMP_FOLDER override', () => { + process.env.RUSH_TEMP_FOLDER = path.join( + __dirname, + 'sandbox', + 'repo', + 'custom-temp', + '..', + 'rush-temp' + ); + EnvironmentConfiguration.reset(); + + const config: MinimalRushConfiguration = + MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; + + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'rush-temp')); + }); + }); + + it('preserves legacy discovery text and blank-line behavior exactly', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '2.5.0' }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--verbose']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it('prints the legacy discovery line and blank line when rush.json is in the current folder', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(legacyRepo); + process.argv = ['node', 'rush', 'build', '--verbose']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it.each<[string, string[], string | undefined, boolean]>([ + ['legacy', ['build', '--verbose'], undefined, true], + ['explicit JSON', ['build', '--reporter=json'], undefined, false], + ['explicit legacy', ['build', '--reporter=legacy'], undefined, true], + ['emergency legacy', ['build', '--reporter=json'], 'legacy', true], + ['help', ['build', '--reporter=json', '--help'], undefined, true], + ['custom reporter', ['custom', '--reporter=junit'], undefined, true], + ['custom flag', ['custom', '--reporter', '--verbose'], undefined, true], + [ + 'reporter-shaped custom values', + ['custom-output', '--output=json://./custom.jsonl', '--log-level=debug', '--verbose'], + undefined, + true + ], + ['pass-through', ['build', '--', '--reporter=json'], undefined, true], + ['quiet', ['--quiet', 'build'], undefined, false] + ])('preserves discovery ownership when rush.json cannot load: %s', (name, args, reporter, visible) => { + void name; + const repo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(repo, 'project')); + jest.spyOn(JsonFile, 'load').mockImplementation(() => { + throw new SyntaxError('Malformed rush.json'); + }); + process.argv = ['node', 'rush', ...args]; + if (reporter === undefined) { + delete process.env.RUSH_REPORTER; + } else { + process.env.RUSH_REPORTER = reporter; + } + + expect(MinimalRushConfiguration.loadFromDefaultLocation()).toBeUndefined(); + expect(consoleLog.mock.calls).toEqual( + visible ? [[`Found configuration in ${path.join(repo, 'rush.json')}`], ['']] : [] + ); + }); + + it('suppresses legacy discovery output for an explicit reporter', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '2.5.0' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--verbose', '--reporter=json']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('restores legacy discovery output under the emergency fallback', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--reporter=json']; + process.env.RUSH_REPORTER = 'legacy'; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it.each([ + ['environment fallback', ['build', '--reporter=json'], 'legacy'], + ['explicit legacy reporter', ['build', '--reporter=legacy'], undefined], + ['help fallback', ['build', '--reporter=json', '--help'], undefined], + ['cross-version fallback', ['build', '--reporter=json'], undefined] + ])('restores legacy discovery output in an opted-in repository for %s', (testName, args, envValue) => { + void testName; + const repo: string = path.join(__dirname, 'sandbox', 'repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(repo, 'project')); + process.argv = ['node', 'rush', ...args]; + if (envValue === undefined) { + delete process.env.RUSH_REPORTER; + } else { + process.env.RUSH_REPORTER = envValue; + } + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([[`Found configuration in ${path.join(repo, 'rush.json')}`], ['']]); + }); + + it.each([ + ['custom reporter value', ['custom', '--reporter', 'junit']], + ['value-less custom reporter flag', ['custom', '--reporter', '--verbose']], + ['pass-through reporter flag', ['build', '--', '--reporter=json']] + ])('preserves legacy discovery output for %s', (testName, args) => { + void testName; + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', ...args]; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it('uses the effective preview version when deciding discovery ownership', () => { + const repo: string = path.join(__dirname, 'sandbox', 'repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '5.178.1' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(repo, 'project')); + process.argv = ['node', 'rush', 'build', '--reporter=json']; + process.env.RUSH_PREVIEW_VERSION = '5.178.1'; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); }); }); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index d9dabe94c3..206ac01890 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -41,6 +41,7 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'legacy', logLevel: 'normal', @@ -69,6 +70,7 @@ async function createEnabledHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'json', logLevel: 'normal', @@ -106,6 +108,7 @@ async function createPhaseHangingHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'json', logLevel: 'normal', @@ -262,6 +265,43 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('keeps an active purge reporter log outside the temp folder being purged', async () => { + const order: string[] = []; + let commonTempFolder: string | undefined = 'not-captured'; + let actionName: string | undefined; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'purge', '--reporter=file']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { + commonTempFolder: '/repo/common/temp', + useRushReporter: false + } as MinimalRushConfiguration, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + commonTempFolder = options.commonTempFolder; + actionName = options.actionName; + return createInitializedHostAsync(order, 'explicit --reporter'); + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(actionName).toBe('purge'); + expect(commonTempFolder).toBeUndefined(); + } 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'); @@ -626,7 +666,7 @@ describe(launchRushFrontendAsync.name, () => { expect(selection).toMatchObject({ reporter: 'legacy', enabled: false, - reporterControlsOwnedByFrontend: false + reporterControlsOwnedByFrontend: rollback }); expect( JSON.parse( diff --git a/apps/rush/src/test/RushReporterArtifactClose.test.ts b/apps/rush/src/test/RushReporterArtifactClose.test.ts new file mode 100644 index 0000000000..b382893e62 --- /dev/null +++ b/apps/rush/src/test/RushReporterArtifactClose.test.ts @@ -0,0 +1,117 @@ +// 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 { IReporterEventEnvelope } from '@rushstack/rush-reporter'; + +import { initializeRushReporterHostAsync } from '../RushReporterHost'; + +describe('reporter artifact completion boundary', () => { + it.each(['success', 'fsync failure', 'close failure'] as const)( + 'publishes final artifact status only after physical closure: %s', + async (outcome) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-artifact-close-')); + const fsModule: typeof fs = jest.requireActual('node:fs'); + const originalClose: typeof fs.closeSync = fsModule.closeSync; + const trace: string[] = []; + let output: string = ''; + const warnings: string[] = []; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--log-level=debug'], + env: {}, + commonTempFolder: directory, + stdout: { + isTTY: false, + write: (text: string) => { + output += text; + const event = JSON.parse(text) as IReporterEventEnvelope<{ complete?: boolean }>; + if (event.type === 'artifactAvailable' && event.payload.complete === true) { + trace.push('complete notification'); + } + } + } + }); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation((text) => { + warnings.push(String(text)); + return true; + }); + const closeSpy = jest.spyOn(fsModule, 'closeSync').mockImplementation((fd: number) => { + trace.push('close attempt'); + originalClose(fd); + if (outcome === 'close failure') { + throw new Error('injected close failure'); + } + trace.push('close succeeded'); + }); + const fsyncSpy = jest.spyOn(fsModule, 'fsyncSync'); + if (outcome === 'fsync failure') { + fsyncSpy.mockImplementation(() => { + throw new Error('injected fsync failure'); + }); + } + const base = { + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' } + }; + try { + initialized.sink.emit({ + ...base, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { role: 'log', path: initialized.logArtifact?.path, format: 'plaintext', complete: false } + }); + initialized.sink.emit({ + ...base, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + initialized.sink.emit({ + ...base, + privacy: 'public', + type: 'sessionCompleted', + payload: { exitCode: 0 } + }); + await initialized.closeAsync(); + await initialized.closeAsync(); + + const events: IReporterEventEnvelope<{ complete?: boolean; exitCode?: number }>[] = output + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const completeEvents = events.filter( + (event) => event.type === 'artifactAvailable' && event.payload.complete === true + ); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(events.find((event) => event.type === 'commandResult')?.payload.exitCode).toBe(0); + if (outcome === 'success') { + expect(completeEvents).toHaveLength(1); + expect(trace).toEqual(['close attempt', 'close succeeded', 'complete notification']); + const log: string = await fs.promises.readFile(initialized.logArtifact!.path!, 'utf8'); + expect(log).toContain('"type":"commandResult"'); + expect(log).toContain('"type":"sessionCompleted"'); + const metadata = log + .split('\n') + .filter((line) => line.startsWith('# {')) + .map((line) => JSON.parse(line.slice(2))); + expect( + metadata.some((event) => event.type === 'artifactAvailable' && event.payload.complete === true) + ).toBe(false); + expect(warnings).toEqual([]); + } else { + expect(completeEvents).toHaveLength(0); + expect(warnings.some((warning) => warning.includes(`injected ${outcome}`))).toBe(true); + } + } finally { + fsyncSpy.mockRestore(); + closeSpy.mockRestore(); + stderrSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); +}); diff --git a/apps/rush/src/test/RushReporterHelp.test.ts b/apps/rush/src/test/RushReporterHelp.test.ts new file mode 100644 index 0000000000..d7dfacc507 --- /dev/null +++ b/apps/rush/src/test/RushReporterHelp.test.ts @@ -0,0 +1,209 @@ +// 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 type { ICommandLineJson } from '@microsoft/rush-lib/lib/api/CommandLineJson'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { JsonFile } from '@rushstack/node-core-library'; + +import { launchRushFrontendAsync } from '../RushFrontend'; +import { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +describe('reporter help forwarding', () => { + it.each([ + { optIn: false, command: undefined, verbose: undefined }, + { optIn: true, command: undefined, verbose: undefined }, + { optIn: false, command: 'build', verbose: undefined }, + { optIn: true, command: 'build', verbose: undefined }, + { optIn: false, command: 'list', verbose: '--verbose' }, + { optIn: true, command: 'list', verbose: '--verbose' }, + { optIn: true, command: 'build', verbose: '--verbose' }, + { optIn: true, command: 'list', verbose: '-v' } + ])('forwards command-owned help flags to the real engine: %j', async ({ optIn, command, verbose }) => { + const repoPath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo' + ); + const originalArgv: string[] = process.argv; + const originalEnv: NodeJS.ProcessEnv = { ...process.env }; + const originalExitCode: typeof process.exitCode = process.exitCode; + const output: string[] = []; + const errors: string[] = []; + const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation((text) => { + output.push(String(text)); + return true; + }); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation((text) => { + errors.push(String(text)); + return true; + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation((text) => output.push(String(text))); + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(repoPath); + process.argv = [ + 'node', + 'rush', + ...(command ? [command] : []), + ...(optIn ? [] : ['--reporter=json']), + '--output=json://./help-events.jsonl', + '--log-level=debug', + ...(verbose ? [verbose] : []), + '--help' + ]; + delete process.env.RUSH_REPORTER; + process.env.RUSH_LOG_LEVEL = 'debug'; + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: optIn } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + expect(process.argv).toEqual([ + 'node', + 'rush', + ...(command ? [command] : []), + ...(verbose && (verbose === '-v' || command === 'build') ? [verbose] : []), + '--help' + ]); + expect(process.env.RUSH_LOG_LEVEL).toBeUndefined(); + expect(options.reporter.operationStreamEnabled).toBe(false); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: options.reporterCloseAsync + }); + return parser.executeAsync().then((succeeded) => { + expect(succeeded).toBe(true); + }); + } + }); + expect(output.join('')).toContain(command ? `usage: rush ${command}` : 'usage: rush'); + expect(errors).toEqual([]); + } finally { + process.argv = originalArgv; + process.env = originalEnv; + process.exitCode = originalExitCode; + EnvironmentConfiguration.reset(); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + cwdSpy.mockRestore(); + } + }); + + it.each([ + { command: 'custom-output', globalHelp: false, customValueControls: ['--output', '--log-level'] }, + { command: 'custom-output', globalHelp: true, customValueControls: ['--output', '--log-level'] }, + { command: 'build', globalHelp: false, customValueControls: ['--output', '--log-level'] }, + { command: 'custom-output', globalHelp: false, customValueControls: ['--output'] }, + { command: 'custom-output', globalHelp: false, customValueControls: ['--log-level'] } + ])('preserves declared reporter-shaped parameters for opted-in help: %j', async (testCase) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-help-')); + const repoPath: string = path.join(directory, 'repo'); + await fs.promises.cp( + path.resolve(__dirname, '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo'), + repoPath, + { recursive: true } + ); + const commandLinePath: string = path.join(repoPath, 'common/config/rush/command-line.json'); + const commandLine: ICommandLineJson = JsonFile.load(commandLinePath); + commandLine.parameters = commandLine.parameters?.filter( + (parameter) => + (parameter.longName !== '--output' && parameter.longName !== '--log-level') || + testCase.customValueControls.includes(parameter.longName) + ); + for (const parameter of commandLine.parameters ?? []) { + if (parameter.longName === '--output' || parameter.longName === '--log-level') { + parameter.associatedCommands?.push('build'); + } + } + JsonFile.save(commandLine, commandLinePath); + const originalArgv: string[] = process.argv; + const originalEnv: NodeJS.ProcessEnv = { ...process.env }; + const originalExitCode: typeof process.exitCode = process.exitCode; + const output: string[] = []; + const errors: string[] = []; + const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation((text) => { + output.push(String(text)); + return true; + }); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation((text) => { + errors.push(String(text)); + return true; + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation((text) => output.push(String(text))); + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(repoPath); + process.argv = [ + 'node', + 'rush', + ...(testCase.globalHelp ? ['--help'] : []), + testCase.command, + '--output=json://./custom-events.jsonl', + '--log-level=debug', + '--verbose', + ...(testCase.globalHelp ? [] : ['--help']) + ]; + const expectedArgv: string[] = process.argv.filter( + (argument) => + (!argument.startsWith('--output=') || testCase.customValueControls.includes('--output')) && + (!argument.startsWith('--log-level=') || testCase.customValueControls.includes('--log-level')) + ); + delete process.env.RUSH_REPORTER; + delete process.env.RUSH_LOG_LEVEL; + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + const forwardedArgv: string[] = [...process.argv]; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: options.reporterCloseAsync + }); + return parser.executeAsync().then((succeeded) => { + expect(succeeded).toBe(true); + expect(forwardedArgv).toEqual(expectedArgv); + }); + } + }); + expect(output.join('')).toContain( + testCase.globalHelp ? 'usage: rush' : `usage: rush ${testCase.command}` + ); + expect(errors).toEqual([]); + expect(fs.existsSync(path.join(repoPath, 'custom-events.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(repoPath, 'custom-output-args.json'))).toBe(false); + + await fs.promises.writeFile(path.join(repoPath, 'rush.json'), '{ malformed rush.json'); + logSpy.mockClear(); + expect(MinimalRushConfiguration.loadFromDefaultLocation()).toBeUndefined(); + expect(logSpy.mock.calls).toEqual([ + [`Found configuration in ${path.join(repoPath, 'rush.json')}`], + [''] + ]); + } finally { + process.argv = originalArgv; + process.env = originalEnv; + process.exitCode = originalExitCode; + EnvironmentConfiguration.reset(); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + cwdSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index f12092e258..6f913501b4 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -6,6 +6,9 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { + ReporterManager, + type IReporter, + type IReporterContext, OldEngineOutputAdapter, type IReporterEventEnvelope, type IReporterEventSink @@ -160,6 +163,23 @@ describe(resolveRushReporterSelection.name, () => { }); }); + it('owns standalone log-level controls when the repository experiment is enabled', () => { + expect(resolve(['build', '--log-level=debug'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--log-level'] + }); + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'debug' }, false, true)).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: [] + }); + }); + it('preserves custom value parameters when the repository experiment selects the reporter implicitly', () => { expect( resolve( @@ -251,7 +271,7 @@ describe(resolveRushReporterSelection.name, () => { expect(selection).toMatchObject({ enabled: false, reporter: 'legacy', - reporterControlsOwnedByFrontend: false, + reporterControlsOwnedByFrontend: true, reporterValueFlagsToStrip: [] }); expect( @@ -286,6 +306,145 @@ describe(resolveRushReporterSelection.name, () => { ]); }); + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + + it.each([ + ['build', '--reporter=json', '--output=file://./help.log', '--log-level=debug', '--help'], + ['build', '--help', '--reporter=default', '--output', 'json://./events.jsonl', '--log-level', 'quiet'] + ])('strips explicit reporter-owned value controls for help: %s', (...argv: string[]) => { + const selection: IRushReporterSelection = resolve(argv); + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--help' + ]); + }); + + it.each([ + [ + ['build', '--output=json://./events.jsonl', '--log-level=debug', '--help'], + ['build', '--help'] + ], + [ + ['build', '--log-level=debug', '--help'], + ['build', '--help'] + ], + [ + ['custom', '--output', 'artifact.zip', '--log-level', 'custom-level', '--help'], + ['custom', '--output', 'artifact.zip', '--log-level', 'custom-level', '--help'] + ], + [ + ['custom', '--output', 'artifact.zip', '--log-level', 'debug', '--help'], + ['custom', '--output', 'artifact.zip', '--log-level', 'debug', '--help'] + ], + [ + ['custom', '--output', '--log-level=debug', '--help'], + ['custom', '--output', '--log-level=debug', '--help'] + ], + [ + ['custom', '--output=file://./log', '--log-level=custom', '--help'], + ['custom', '--output=file://./log', '--log-level=custom', '--help'] + ], + [ + ['custom', '--output=file://./log', '--output=custom.zip', '--log-level=debug', '--help'], + ['custom', '--output=file://./log', '--output=custom.zip', '--log-level=debug', '--help'] + ], + [ + ['custom', '--log-level', '--help'], + ['custom', '--log-level', '--help'] + ], + [ + ['plugin-command', '--output=json://./custom.jsonl', '--log-level=debug', '--verbose', '--help'], + ['plugin-command', '--output=json://./custom.jsonl', '--log-level=debug', '--verbose', '--help'] + ], + [ + ['build', '--log-level=debug', '--help', '--', '--output=json://./child'], + ['build', '--help', '--', '--output=json://./child'] + ] + ])('uses selective implicit ownership for repository help: %j', (argv, expected) => { + const selection: IRushReporterSelection = resolve(argv, {}, false, true); + expect(selection.enabled).toBe(false); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual(expected); + }); + + it('owns RUSH_LOG_LEVEL for repository help without enabling reporters', () => { + expect(resolve(['build', '--help'], { RUSH_LOG_LEVEL: 'debug' }, false, true)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: [] + }); + expect(resolve(['custom', '--help'], { RUSH_LOG_LEVEL: 'debug' })).toMatchObject({ + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + }); + + it('does not use implicit reporter value controls to opt in on help', () => { + const argv: string[] = ['custom', '--output=file://./log', '--log-level=debug', '--help']; + const selection: IRushReporterSelection = resolve(argv); + expect(selection.reporterControlsOwnedByFrontend).toBe(false); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual(argv); + }); + + it('preserves command-owned help values under explicit and emergency legacy', () => { + const argv: string[] = [ + 'custom', + '--reporter=legacy', + '--output', + 'artifact.zip', + '--log-level', + 'custom', + '--help' + ]; + for (const env of [{}, { RUSH_REPORTER: 'legacy', RUSH_LOG_LEVEL: 'invalid' }]) { + const selection: IRushReporterSelection = resolve(argv, env, false, true); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'custom', + '--output', + 'artifact.zip', + '--log-level', + 'custom', + '--help' + ]); + } + }); + + it('does not consume following flags while stripping incomplete owned controls for help', () => { + expect(stripReporterValueControls(['build', '--reporter=json', '--output', '--help'])).toEqual([ + 'build', + '--help' + ]); + expect( + stripReporterValueControls([ + 'build', + '--reporter=json', + '--log-level', + '--help', + '--', + '--output=child' + ]) + ).toEqual(['build', '--help', '--', '--output=child']); + }); + + it('ignores help controls after the pass-through separator', () => { + expect(resolve(['build', '--reporter=json', '--', '--help'])).toMatchObject({ + reporter: 'json', + enabled: true, + reporterControlsOwnedByFrontend: true + }); + }); + it.each(['--reporter', '--output', '--log-level'])( 'does not consume legacy flags after a value-less %s during rollback', (flag) => { @@ -390,6 +549,13 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('preserves RUSH_QUIET_MODE as a quiet reporter alias', () => { + expect(resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: 'true' }).logLevel).toBe('quiet'); + expect(() => + resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: '1', RUSH_LOG_LEVEL: 'debug' }) + ).toThrow(/contradicts RUSH_LOG_LEVEL/); + }); + it('defaults only an unqualified primary file reporter to debug', () => { expect(resolve(['build', '--reporter=file']).logLevel).toBe('debug'); expect(resolve(['build', '--reporter=plaintext']).logLevel).toBe('normal'); @@ -484,21 +650,24 @@ describe(resolveRushReporterSelection.name, () => { expect(resolve(['build', '--reporter=default'], {}, true).reporter).toBe('default'); }); - it('parses output targets and preserves command-specific --json independently', () => { + it('preserves command-specific --json as the sole stdout owner', () => { + expect(() => resolve(['list', '--json', '--reporter=json'])).toThrow( + /command-specific --json output owns stdout/ + ); + const selection: IRushReporterSelection = resolve( - [ - 'list', - '--json', - '--reporter=json', - '--output=file://./rush.log?logLevel=debug', - '--output=json://./events.jsonl' - ], + ['list', '--json', '--output=file://./rush.log?logLevel=debug', '--output=json://./events.jsonl'], {}, - false + false, + true ); - expect(selection.commandJson).toBe(true); - expect(selection.reporter).toBe('json'); + expect(selection).toMatchObject({ + commandJson: true, + reporter: 'file', + enabled: true, + reason: 'repository experiment' + }); expect(selection.outputs).toEqual([ { reporter: 'file', @@ -511,6 +680,21 @@ describe(resolveRushReporterSelection.name, () => { params: {} } ]); + expect(selection).toMatchObject({ + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--output'] + }); + expect(resolve(['list', '--json', '--reporter=file']).reporter).toBe('file'); + }); + + it('forces legacy selection for an incompatible Rush engine', () => { + expect(() => + resolveRushReporterSelection({ + argv: ['build', '--reporter=json'], + env: {}, + forceLegacy: true + }) + ).toThrow(/cannot safely use --reporter=json/); }); it('surfaces unsupported and incomplete controls with actionable errors', () => { @@ -544,7 +728,7 @@ describe(resolveRushReporterSelection.name, () => { describe(initializeRushReporterHostAsync.name, () => { it.each([false, true])( - 'retains primary file debug details unless normal is explicit: %s', + 'retains full log debug details independently of the selected level: %s', async (normal) => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-level-')); const osModule: typeof os = jest.requireActual('node:os'); @@ -556,6 +740,7 @@ describe(initializeRushReporterHostAsync.name, () => { stdout: { write: () => undefined }, includeDefaultFileReporter: false }); + expect(initialized.selection.logLevel).toBe(normal ? 'normal' : 'debug'); initialized.sink.emit({ protocolVersion: { major: 1, minor: 0 }, sessionId: 'primary-file-level', @@ -573,7 +758,7 @@ describe(initializeRushReporterHostAsync.name, () => { ); expect(logName).toBeDefined(); const text: string = await fs.promises.readFile(path.join(directory, logFolder, logName!), 'utf8'); - expect(text.includes('retained-debug-detail')).toBe(!normal); + expect(text).toContain('retained-debug-detail'); } finally { tmpdirSpy.mockRestore(); await fs.promises.rm(directory, { recursive: true, force: true }); @@ -582,16 +767,18 @@ describe(initializeRushReporterHostAsync.name, () => { ); it.each([ - { target: 'stdout', outputs: ['json://stdout'] }, - { target: 'stderr', outputs: ['json://stderr', 'file://stderr'] } - ])('rejects conflicting $target ownership before opening files', async ({ target, outputs }) => { + { reporter: 'json', target: 'stdout', outputs: ['json://stdout'] }, + { reporter: 'json', target: 'stderr', outputs: ['json://stderr', 'file://stderr'] }, + { reporter: 'file', target: 'stderr', outputs: ['json://stderr'] } + ])('rejects conflicting $target ownership before opening files', async ({ reporter, target, outputs }) => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-conflict-')); try { await expect( initializeRushReporterHostAsync({ - argv: ['build', '--reporter=json', ...outputs.map((output) => `--output=${output}`)], + argv: ['build', `--reporter=${reporter}`, ...outputs.map((output) => `--output=${output}`)], env: {}, cwd: directory, + commonTempFolder: directory, stdout: { write: () => undefined }, includeDefaultFileReporter: false }).then(async (initialized) => { @@ -609,15 +796,14 @@ describe(initializeRushReporterHostAsync.name, () => { 'writes reserved %s output to the stream without creating a same-named file', async (target) => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-output-')); - const osModule: typeof os = jest.requireActual('node:os'); - const tmpdirSpy: jest.SpyInstance = jest.spyOn(osModule, 'tmpdir').mockReturnValue(directory); const stdout = { write: jest.fn(), end: jest.fn() }; const stderr = { write: jest.fn(), end: jest.fn() }; try { const initialized = await initializeRushReporterHostAsync({ - argv: ['build', '--reporter=file', `--output=json://${target}`], + argv: ['build', `--reporter=${target === 'stdout' ? 'file' : 'json'}`, `--output=json://${target}`], env: {}, cwd: directory, + commonTempFolder: directory, stdout, stderr, includeDefaultFileReporter: false @@ -626,16 +812,20 @@ describe(initializeRushReporterHostAsync.name, () => { await initialized.closeAsync(); const stream = target === 'stdout' ? stdout : stderr; - expect(JSON.parse(stream.write.mock.calls.map(([text]) => text).join('')).type).toBe( - 'commandStarted' - ); + expect( + stream.write.mock.calls + .map(([text]) => text) + .join('') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + ).toContainEqual(expect.objectContaining({ type: 'commandStarted' })); expect(stdout.end).not.toHaveBeenCalled(); expect(stderr.end).not.toHaveBeenCalled(); await expect(fs.promises.stat(path.join(directory, target))).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - tmpdirSpy.mockRestore(); await fs.promises.rm(directory, { recursive: true, force: true }); } } @@ -682,9 +872,120 @@ describe(initializeRushReporterHostAsync.name, () => { await initialized.closeAsync(); expect(initialized.selection.enabled).toBe(false); + expect(initialized.logArtifact).toBeUndefined(); expect(output).toBe(''); }); + it('always creates a repository full-detail log on the enabled path', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-full-log-')); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined } + }); + + expect(initialized.logArtifact).toMatchObject({ available: true }); + expect(initialized.logArtifact?.path).toMatch( + new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'automatic-file-level', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'messageEmitted', + payload: { severity: 'debug', text: 'automatic-full-detail' } + }); + await initialized.closeAsync(); + const logPath: string | undefined = initialized.logArtifact?.path; + if (!logPath) { + throw new Error('Expected the automatic full-detail log path'); + } + expect(await fs.promises.readFile(logPath, 'utf8')).toContain('automatic-full-detail'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('prints the file path for a parser-only failure without commandResult', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-only-')); + let stderrText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['missing-command', '--reporter=file'], + env: {}, + commonTempFolder: directory, + actionName: 'missing-command', + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'sessionCompleted', + payload: { exitCode: 1 } + }); + await initialized.closeAsync(); + + expect(stderrText.match(/Rush full log:/g)).toHaveLength(1); + expect(stderrText).toContain(initialized.logArtifact?.path); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not render operation output at quiet plaintext log level', async () => { + let output: string = ''; + const quietHost = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext', '--log-level=quiet'], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + output += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(quietHost.sink); + emitOperationEvents(quietHost.sink); + quietHost.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await quietHost.closeAsync(); + + expect(output).not.toContain('raw operation output'); + expect(output).toContain('rush build succeeded'); + }); + 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'); @@ -721,7 +1022,14 @@ describe(initializeRushReporterHostAsync.name, () => { .trim() .split('\n') .map((line: string) => JSON.parse(line) as Record); - expect(stdoutEvents.map(({ type }) => type)).toEqual(['commandStarted']); + expect(stdoutEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); expect(fileEvents.map(({ type }) => type)).toEqual([ 'commandStarted', 'operationRegistered', @@ -735,8 +1043,127 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('publishes a completed artifact before the final AI record', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-ai-artifact-')); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=ai'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + } + }); + + emitCommandStarted(initialized.sink); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await initialized.closeAsync(); + + const finalRecord: { log?: { complete?: boolean; path?: string } } = JSON.parse( + stdoutText.trim().split('\n').at(-1)! + ); + expect(finalRecord.log).toMatchObject({ + complete: true, + path: initialized.logArtifact?.path + }); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('publishes artifact completeness as a frozen boolean snapshot', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-artifact-snapshot-')); + const reported: IReporterEventEnvelope[] = []; + const manager: ReporterManager = new ReporterManager(); + const captureReporter: IReporter = { + name: 'capture', + initializeAsync: async (context: IReporterContext) => { + void context; + }, + report: (event: IReporterEventEnvelope) => { + reported.push(event); + }, + flushAsync: async () => undefined, + closeAsync: async () => undefined + }; + manager.addReporter(captureReporter); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined }, + manager + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await initialized.closeAsync(); + + const finalArtifact: IReporterEventEnvelope = reported + .filter(({ type }) => type === 'artifactAvailable') + .at(-1)!; + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + finalArtifact.payload as object, + 'complete' + ); + expect(descriptor).toMatchObject({ value: true, writable: false }); + expect(typeof (finalArtifact.payload as { complete: unknown }).complete).toBe('boolean'); + expect(finalArtifact.source).toEqual({ + packageName: '@microsoft/rush', + packageVersion: '5.178.1' + }); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + it.each(['json', 'plaintext'])( - 'preserves unscoped and command-scoped output while deferring collated operations: %s', + 'preserves unscoped and command-scoped output alongside presented operations: %s', async (reporter) => { let output: string = ''; const initialized = await initializeRushReporterHostAsync({ @@ -774,16 +1201,36 @@ describe(initializeRushReporterHostAsync.name, () => { expect(events.map((event) => event.type)).toEqual([ 'commandStarted', 'externalOutput', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted', 'externalOutput', 'externalOutput' ]); - expect(events.slice(1).map((event) => event.payload)).toEqual([ + expect( + events.filter((event) => event.type === 'externalOutput').map((event) => event.payload) + ).toEqual([ { stream: 'stdout', text: 'bootstrap stdout\n' }, + { stream: 'stdout', text: 'raw operation output\n' }, { stream: 'stderr', text: 'bootstrap stderr\n' }, { stream: 'stdout', text: 'command output\n' } ]); } else { - expect(output).toBe('Starting "rush build"\nbootstrap stdout\nbootstrap stderr\ncommand output\n'); + expect(output.startsWith('Starting "rush build"\n')).toBe(true); + let previousOffset: number = -1; + for (const text of [ + 'bootstrap stdout\n', + 'raw operation output\n', + 'bootstrap stderr\n', + 'command output\n' + ]) { + expect(output.split(text)).toHaveLength(2); + const offset: number = output.indexOf(text); + expect(offset).toBeGreaterThan(previousOffset); + previousOffset = offset; + } } } finally { await initialized.closeAsync(); diff --git a/apps/rush/src/test/sandbox/reporter-demo/README.md b/apps/rush/src/test/sandbox/reporter-demo/README.md new file mode 100644 index 0000000000..6ab674b277 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -0,0 +1,37 @@ +# Direct Rush reporter demo + +Build the three reporter projects, then run the self-checking direct invocation demo: + +```sh +rush build --to @microsoft/rush +node apps/rush/src/test/sandbox/reporter-demo/run.mjs +``` + +The script runs the same `rush build --only @rushstack/rush-reporter` operation stream through legacy, +plaintext, JSON, AI, file, and quiet modes, plus parser failure, help, and command-specific JSON cases. +It verifies payload-only machine stdout, one visible writer, ordered/lossless plaintext grouping from a +same-invocation JSON sidecar, final artifact completeness, owner-only log permissions, failure flushing, +AI parser-error context, command-JSON ownership, CI plaintext output, cache-path output, normalized +`RUSH_TEMP_FOLDER` log placement, matching purge-path selection, and the `RUSH_REPORTER=legacy` rollback +transcript. Captured stdout/stderr files are written to a temporary folder. + +For an individual invocation: + +```sh +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json --log-level=debug +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=ai +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=file +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext --log-level=quiet +RUSH_REPORTER=legacy node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json +node apps/rush/bin/rush list --json --reporter=file +``` + +Repositories can opt in without a command-line flag by setting `"useRushReporter": true` in +`common/config/rush/experiments.json`. Remove that setting or use `RUSH_REPORTER=legacy` for immediate +rollback. + +Help stays on the legacy parser path. With repository opt-in, parameters declared for a command remain +command-owned even when their values look like reporter controls (for example, `--output=json://...` +or `--log-level=debug`). Custom `--verbose` flags are preserved as well; help does not run the command +or open reporter output files. diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs new file mode 100644 index 0000000000..ac0142c4ff --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -0,0 +1,222 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptFolder = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptFolder, '..', '..', '..', '..', '..', '..'); +const rushBin = path.join(repoRoot, 'apps', 'rush', 'bin', 'rush'); +const outputFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-reporter-demo-')); +const commonArgs = ['build', '--only', '@rushstack/rush-reporter']; + +function run(name, args, env = {}, expectedStatus = 0) { + const result = spawnSync(process.execPath, [rushBin, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8' + }); + fs.writeFileSync(path.join(outputFolder, `${name}.stdout`), result.stdout); + fs.writeFileSync(path.join(outputFolder, `${name}.stderr`), result.stderr); + if (result.status !== expectedStatus) { + throw new Error( + `${name} exited with ${result.status}; expected ${expectedStatus}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + return result; +} + +run('warmup', commonArgs); +const legacy = run('legacy', commonArgs).stdout; +const rollback = run('rollback', [...commonArgs, '--reporter=json'], { RUSH_REPORTER: 'legacy' }).stdout; +const normalizeDurations = (text) => text.replace(/\d+\.\d+ seconds/g, '