diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 62aef01d11..4d84b61e85 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,14 +56,37 @@ 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); if (minimalRushConfigurationJson) { - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + const configuration: MinimalRushConfiguration = new MinimalRushConfiguration( + minimalRushConfigurationJson, + rushJsonLocation + ); + const explicitReporter: ReporterName | undefined = _getExplicitReporter(process.argv.slice(2)); + const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; + const effectiveRushVersion: string = getRushPreviewVersion() ?? configuration.rushVersion; + const legacyFallbackRequested: boolean = + explicitReporter === 'legacy' || + process.env.RUSH_REPORTER?.trim().toLowerCase() === 'legacy' || + _hasHelpControl(process.argv.slice(2)) || + effectiveRushVersion !== currentPackageVersion; + if ( + showVerbose && + (legacyFallbackRequested || + (!configuration.useRushReporter && + (explicitReporter === undefined || explicitReporter === 'legacy'))) + ) { + // Preserve the legacy discovery message exactly when the reporter path is not taking ownership. + console.log('Found configuration in ' + rushJsonLocation); + console.log(''); + } + return configuration; } return undefined; } else { @@ -94,6 +121,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 00caadebf4..48e04551a5 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) @@ -153,10 +157,27 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr process.argv, new Set(reporterHost.selection.reporterValueFlagsToStrip) ); + 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 82dc2d4f90..82204ecdc4 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'; export interface IRushReporterOutputStream { @@ -39,11 +42,15 @@ export interface IRushReporterHostOptions { readonly env?: Record; 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 { @@ -65,13 +72,15 @@ 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', @@ -93,10 +102,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._preserveOperationStream = preserveOperationStream; this.name = reporter.name; } @@ -105,39 +120,13 @@ class LogLevelReporter implements IReporter { } 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. - */ -class DeferredOperationPresentationReporter implements IReporter { - public readonly name: string; - - private readonly _reporter: IReporter; - - public constructor(reporter: IReporter) { - this._reporter = reporter; - this.name = reporter.name; - } - - public initializeAsync(context: IReporterContext): Promise { - return this._reporter.initializeAsync(context); - } - - public report(event: IReporterEventEnvelope): void { - if (!DEFERRED_OPERATION_EVENT_TYPES.has(event.type)) { + if ( + 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); } } @@ -202,6 +191,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 readValue( argv: readonly string[], index: number, @@ -347,6 +442,34 @@ function hasReporterOutputControl(argv: readonly string[]): boolean { return false; } +function hasReporterLogLevelControl(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + if (argument.startsWith('--log-level=') && argument.length > '--log-level='.length) { + return true; + } + if (argument === '--log-level' && argv[index + 1] !== undefined && !argv[index + 1].startsWith('-')) { + return true; + } + } + 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 resolveLogLevel( controls: IParsedReporterControls, env: Record, @@ -397,6 +520,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)) { @@ -500,7 +636,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson, enabled: false, - reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterControlsOwnedByFrontend: true, reporterValueFlagsToStrip, reason: 'RUSH_REPORTER=legacy' }; @@ -527,6 +663,19 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + if (hasHelpControl(argv)) { + 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') { @@ -548,14 +697,53 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } if (options.repositoryOptIn) { const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const ownsOutputControls: boolean = hasReporterOutputControl(argv); + const ownsLogLevelControls: boolean = hasReporterLogLevelControl(argv); + const candidateValueControls: IParsedReporterControls = + ownsOutputControls || ownsLogLevelControls ? parseReporterControls(argv, true) : selectionControls; + const ownsLogLevelControl: boolean = + ownsLogLevelControls && + !ownsOutputControls && + candidateValueControls.logLevels.length > 0 && + candidateValueControls.logLevels.every((logLevel: string) => isSupportedLogLevel(logLevel)); + const ownsValueControls: boolean = ownsOutputControls || ownsLogLevelControl; + const ownsEnvironmentControls: boolean = env.RUSH_LOG_LEVEL !== undefined; + const parsedValueControls: IParsedReporterControls = ownsValueControls + ? candidateValueControls + : selectionControls; + const outputControlsAreUnambiguous: boolean = + !parsedValueControls.logLevels.some((logLevel: string) => !isSupportedLogLevel(logLevel)) && + parsedValueControls.outputs.every((outputValue: string) => { + try { + const output: IReporterOutputTarget = parseOutputControl(outputValue); + return output.reporter === 'file' || output.reporter === 'json'; + } catch { + return false; + } + }); + const implicitControls: IParsedReporterControls = + ownsOutputControls && !outputControlsAreUnambiguous + ? { + ...parsedValueControls, + logLevels: [], + outputs: [] + } + : parsedValueControls; + validateReporterControlMultiplicity(implicitControls, ownsValueControls); return { - reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', - logLevel: resolveLogLevel(selectionControls, env, true, true), - outputs: [], + reporter: commandJson ? 'file' : isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(implicitControls, env, true, true), + outputs: resolveOutputs(implicitControls.outputs, cwd), commandJson, enabled: true, - reporterControlsOwnedByFrontend: false, - reporterValueFlagsToStrip: [], + reporterControlsOwnedByFrontend: + ownsLogLevelControl || ownsEnvironmentControls || implicitControls.outputs.length > 0, + reporterValueFlagsToStrip: + implicitControls.outputs.length > 0 + ? REPORTER_OUTPUT_VALUE_FLAGS + : ownsLogLevelControl + ? ['--log-level'] + : [], reason: 'repository experiment' }; } @@ -584,6 +772,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; @@ -614,8 +809,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); } @@ -629,11 +828,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; } @@ -644,30 +844,37 @@ export async function initializeRushReporterHostAsync( ): Promise { const env: Record = options.env ?? process.env; 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) { @@ -684,13 +891,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..9894396f92 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -3,11 +3,30 @@ import * as path from 'node:path'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { 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; + 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; + } + EnvironmentConfiguration.reset(); }); describe('legacy rush config', () => { @@ -33,6 +52,157 @@ 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('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 4a59142d85..7c0b6ca3ce 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -40,6 +40,7 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'legacy', logLevel: 'normal', @@ -68,6 +69,7 @@ async function createEnabledHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'json', logLevel: 'normal', @@ -105,6 +107,7 @@ async function createPhaseHangingHostAsync( return { host, sink: host.getSink(), + logArtifact: undefined, selection: { reporter: 'json', logLevel: 'normal', @@ -261,6 +264,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'); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 7846cb673c..920d7f3a23 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -5,7 +5,13 @@ 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 { + ReporterManager, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -156,6 +162,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( @@ -234,6 +257,22 @@ describe(resolveRushReporterSelection.name, () => { ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); }); + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + + it('ignores help controls after the pass-through separator', () => { + expect(resolve(['build', '--reporter=json', '--', '--help'])).toMatchObject({ + reporter: 'json', + enabled: true, + reporterControlsOwnedByFrontend: true + }); + }); + it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -325,6 +364,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('preserves legacy verbosity combinations when the reporter path is disabled', () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', @@ -400,21 +446,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', @@ -427,6 +476,21 @@ describe(resolveRushReporterSelection.name, () => { params: {} } ]); + expect(selection).toMatchObject({ + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--output', '--log-level'] + }); + 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', () => { @@ -466,9 +530,107 @@ 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, '\\$&')}`) + ); + await initialized.closeAsync(); + } 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'); @@ -505,7 +667,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', @@ -518,4 +687,123 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + 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 }); + } + }); }); 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..7879a7bf71 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -0,0 +1,32 @@ +# 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. 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..d61c29f4da --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -0,0 +1,221 @@ +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, '