From 81a178d343d4e3dc864986d420f10dd7918d4047 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 18:37:23 -0700 Subject: [PATCH 1/4] [rush-daemon] Print the native operation summary and duration line for phased requests Fixes #6053 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...rushd-summary-banner_2026-09-24-01-30.json | 11 ++ ...rushd-summary-banner_2026-09-24-01-30.json | 11 ++ common/reviews/api/rush-lib.api.md | 3 + .../rush-daemon/src/PhasedRequestRouter.ts | 15 ++ .../rush-daemon/src/PhasedRequestSummary.ts | 170 ++++++++++++++++++ .../src/test/PhasedRequestSummary.test.ts | 160 +++++++++++++++++ libraries/rush-lib/src/index.ts | 1 + 7 files changed, 371 insertions(+) create mode 100644 common/changes/@microsoft/rush/rushd-summary-banner_2026-09-24-01-30.json create mode 100644 common/changes/@rushstack/rush-daemon/rushd-summary-banner_2026-09-24-01-30.json create mode 100644 libraries/rush-daemon/src/PhasedRequestSummary.ts create mode 100644 libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts diff --git a/common/changes/@microsoft/rush/rushd-summary-banner_2026-09-24-01-30.json b/common/changes/@microsoft/rush/rushd-summary-banner_2026-09-24-01-30.json new file mode 100644 index 0000000000..c7e8ba2f44 --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-summary-banner_2026-09-24-01-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose the internal operation summary printer so the Rush daemon can print the native end-of-run summary.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-summary-banner_2026-09-24-01-30.json b/common/changes/@rushstack/rush-daemon/rushd-summary-banner_2026-09-24-01-30.json new file mode 100644 index 0000000000..89b47051e2 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-summary-banner_2026-09-24-01-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Print the native operation summary tables and the \"rush ()\" line for each phased request, including warm no-op builds.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 512a2dcf7d..ec33d232e5 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1587,6 +1587,9 @@ export type PnpmStoreOptions = PnpmStoreLocation; // @public export type PnpmTrustPolicy = 'no-downgrade' | 'off'; +// @internal +export function _printOperationStatus(terminal: ITerminal, result: IExecutionResult): void; + // @beta (undocumented) export class ProjectChangeAnalyzer { constructor(rushConfiguration: RushConfiguration); diff --git a/libraries/rush-daemon/src/PhasedRequestRouter.ts b/libraries/rush-daemon/src/PhasedRequestRouter.ts index 0903eaf136..67d0d18c0e 100644 --- a/libraries/rush-daemon/src/PhasedRequestRouter.ts +++ b/libraries/rush-daemon/src/PhasedRequestRouter.ts @@ -19,6 +19,7 @@ import type { import { PhasedRequestEventSink } from './PhasedRequestEventSink'; import { PhasedRequestEventMultiplexer } from './PhasedRequestEventMultiplexer'; +import { writePhasedRequestSummary } from './PhasedRequestSummary'; import type { IPhasedRequestClient } from './PhasedRequestClient'; import { DaemonRequiresInProcessError, evaluateDaemonTerminalPolicy } from './DaemonTerminalPolicy'; import type { IInteractiveRequestSession } from './InteractiveRequestInputRouter'; @@ -66,6 +67,8 @@ interface IPreparedPhasedRequest { readonly interactiveSession: IInteractiveRequestSession | undefined; readonly request: IDaemonPhasedRequest; readonly selection: IResolvedSelection; + /** The `performance.now()` timestamp at which the router received the request. */ + readonly startTimeMs: number; readonly warningsAllowedByEnvironment: boolean; } @@ -113,6 +116,7 @@ export class PhasedRequestRouter { exactSelection: boolean = false, onExecutionStarting?: () => void ): Promise { + const startTimeMs: number = performance.now(); validateRequestIdentity(request); const interactiveSession: IInteractiveRequestSession | undefined = validateInteractiveSession( request, @@ -190,6 +194,7 @@ export class PhasedRequestRouter { interactiveSession, request, selection, + startTimeMs, warningsAllowedByEnvironment, onExecutionStarting }, @@ -540,6 +545,16 @@ class PhasedRequestBatchCoordinator { } const cleanupErrors: unknown[] = [...batchCleanupErrors]; if (entry.requestSink) { + if (entry.participated && this.#isEntryLive(entry)) { + writePhasedRequestSummary({ + activeOperations: entry.selection.activeOperations, + commandName: entry.request.commandName, + elapsedMs: performance.now() - entry.startTimeMs, + executionError, + graph: this.#graph, + sink: entry.requestSink + }); + } try { await entry.requestSink.flushAsync(); } catch (error) { diff --git a/libraries/rush-daemon/src/PhasedRequestSummary.ts b/libraries/rush-daemon/src/PhasedRequestSummary.ts new file mode 100644 index 0000000000..3ee3c79b73 --- /dev/null +++ b/libraries/rush-daemon/src/PhasedRequestSummary.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { + IOperationExecutionResult, + IOperationGraph, + Operation, + _IOperationActivityOptions +} from '@microsoft/rush-lib'; +import { OperationStatus, _printOperationStatus } from '@microsoft/rush-lib'; +import { Terminal, TerminalProviderSeverity, type ITerminalProvider } from '@rushstack/terminal'; + +const SECONDS_PER_MINUTE: number = 60; +const MILLISECONDS_PER_SECOND: number = 1000; +const SUMMARIZED_STATUSES: ReadonlySet = new Set([ + OperationStatus.Aborted, + OperationStatus.Blocked, + OperationStatus.Failure, + OperationStatus.FromCache, + OperationStatus.NoOp, + OperationStatus.Skipped, + OperationStatus.Success, + OperationStatus.SuccessWithWarning +]); + +/** The subset of a request event sink used to render a request's end-of-run summary. */ +export interface IPhasedRequestSummarySink { + getObservedResult(operation: Operation): { readonly executionResult: IOperationExecutionResult } | undefined; + onActivity(text: string, options?: _IOperationActivityOptions): void; +} + +export interface IWritePhasedRequestSummaryOptions { + readonly activeOperations: ReadonlyArray; + readonly commandName: string; + readonly elapsedMs: number; + readonly executionError: unknown; + readonly graph: IOperationGraph; + readonly sink: IPhasedRequestSummarySink; +} + +/** + * Buffers terminal output into request-scoped activity events, one event per contiguous stream run. + */ +class RequestActivityTerminalProvider implements ITerminalProvider { + public readonly supportsColor: boolean = false; + public readonly eolCharacter: string = '\n'; + readonly #sink: IPhasedRequestSummarySink; + #buffer: string = ''; + #stderr: boolean = false; + + public constructor(sink: IPhasedRequestSummarySink) { + this.#sink = sink; + } + + public write(text: string, severity: TerminalProviderSeverity): void { + if (severity === TerminalProviderSeverity.verbose || severity === TerminalProviderSeverity.debug) { + return; + } + const stderr: boolean = + severity === TerminalProviderSeverity.error || severity === TerminalProviderSeverity.warning; + if (stderr !== this.#stderr) { + this.flush(); + this.#stderr = stderr; + } + this.#buffer += text; + } + + public flush(): void { + if (this.#buffer.length > 0) { + this.#sink.onActivity(this.#buffer, { stderr: this.#stderr }); + this.#buffer = ''; + } + } +} + +/** + * Writes the native end-of-run summary (the status tables and the `rush ()` line) for one + * phased request into that request's own event sink. + * + * @remarks + * Coalesced requests share one graph iteration, so the summary is computed per request from the request's own + * selection rather than from the whole iteration. Selected operations that the warm graph did not need to run are + * reported as already up to date, so a warm no-op still reports what it checked. + */ +export function writePhasedRequestSummary(options: IWritePhasedRequestSummaryOptions): void { + const { commandName, elapsedMs, executionError, sink } = options; + const provider: RequestActivityTerminalProvider = new RequestActivityTerminalProvider(sink); + const terminal: Terminal = new Terminal(provider); + const duration: string = formatDuration(elapsedMs); + if (executionError === undefined) { + const operationResults: ReadonlyMap = + collectSummaryResults(options); + _printOperationStatus(terminal, { operationResults, status: getSummaryStatus(operationResults) }); + terminal.writeLine(`rush ${commandName} (${duration})`); + } else { + terminal.writeErrorLine(`rush ${commandName} - Errors! (${duration})`); + } + provider.flush(); +} + +function collectSummaryResults( + options: IWritePhasedRequestSummaryOptions +): ReadonlyMap { + const { activeOperations, graph, sink } = options; + const active: ReadonlySet = new Set(activeOperations); + const results: Map = new Map(); + // Iterate the graph so the summary lists operations in the same order as the native summary. + for (const operation of graph.operations) { + if (!active.has(operation) || operation.runner?.silent !== false) { + continue; + } + const observed: IOperationExecutionResult | undefined = + sink.getObservedResult(operation)?.executionResult; + if (observed && !observed.silent) { + if (SUMMARIZED_STATUSES.has(observed.status)) { + results.set(operation, observed); + } + continue; + } + // A silent observed record belongs to an operation the graph disabled because it was already up to date. + const previous: IOperationExecutionResult | undefined = + observed ?? graph.resultByOperation.get(operation); + if (previous) { + results.set(operation, createUpToDateResult(previous)); + } + } + return results; +} + +function createUpToDateResult(previous: IOperationExecutionResult): IOperationExecutionResult { + // The summary only reads these members for skipped operations; the shared record itself must not change. + const upToDate: Pick = { + operation: previous.operation, + silent: false, + status: OperationStatus.Skipped, + stopwatch: previous.stopwatch + }; + return upToDate as IOperationExecutionResult; +} + +function getSummaryStatus(results: ReadonlyMap): OperationStatus { + let status: OperationStatus = OperationStatus.Success; + for (const [operation, result] of results) { + switch (result.status) { + case OperationStatus.Failure: + case OperationStatus.Blocked: + return OperationStatus.Failure; + case OperationStatus.Aborted: + status = OperationStatus.Aborted; + break; + case OperationStatus.SuccessWithWarning: + if (status === OperationStatus.Success && !operation.runner?.warningsAreAllowed) { + status = OperationStatus.SuccessWithWarning; + } + break; + } + } + return status; +} + +/** Matches the native Rush stopwatch format, for example `1.23 seconds` or `2 minutes 3.4 seconds`. */ +function formatDuration(elapsedMs: number): string { + const totalSeconds: number = elapsedMs / MILLISECONDS_PER_SECOND; + if (totalSeconds > SECONDS_PER_MINUTE) { + const minutes: number = Math.floor(totalSeconds / SECONDS_PER_MINUTE); + const seconds: number = totalSeconds % SECONDS_PER_MINUTE; + return `${minutes.toFixed(0)} minute${minutes === 1 ? '' : 's'} ${seconds.toFixed(1)} seconds`; + } + return `${totalSeconds.toFixed(2)} seconds`; +} diff --git a/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts b/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts new file mode 100644 index 0000000000..81aab099e7 --- /dev/null +++ b/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IConfigurableOperation, Operation } from '@microsoft/rush-lib'; +import { OperationStatus } from '@microsoft/rush-lib'; +import type { IDaemonPhasedOperationSelection, IDaemonPhasedRequest } from '@rushstack/rush-daemon-protocol'; + +import { PhasedRequestRouter } from '../PhasedRequestRouter'; +import { + TEST_ENGINE_SHAPE, + TestOperationRunner, + TestPhasedRequestClient, + createRoutingFixture +} from './PhasedRequestRouterTestUtilities'; +import type { ITestRoutingFixture } from './PhasedRequestRouterTestUtilities'; + +const OPERATION_A: string = 'project-a (_phase:test)'; +const OPERATION_B: string = 'project-b (_phase:test)'; +const OPERATION_C: string = 'project-c (_phase:test)'; +const DURATION_LINE: RegExp = /^rush build \(\d+\.\d\d seconds\)$/m; + +function createRequest(requestId: string, ...operationIds: string[]): IDaemonPhasedRequest { + return { + commandName: 'build', + commandOrigin: 'built-in', + engineShape: TEST_ENGINE_SHAPE, + environment: {}, + operationSelection: operationIds.map( + (operationId: string): IDaemonPhasedOperationSelection => ({ enabledState: true, operationId }) + ), + requestId + }; +} + +function createFixture(statusA: OperationStatus = OperationStatus.Success): ITestRoutingFixture { + return createRoutingFixture( + new Map([ + [OPERATION_A, new TestOperationRunner(OPERATION_A, statusA)], + [OPERATION_B, new TestOperationRunner(OPERATION_B)], + [OPERATION_C, new TestOperationRunner(OPERATION_C)] + ]), + [[OPERATION_B, OPERATION_A]] + ); +} + +function getActivity(client: TestPhasedRequestClient, stream: 'stdout' | 'stderr'): string { + let text: string = ''; + for (const { event } of client.writes) { + const payload: { stream?: string; text?: string } | undefined = + event?.type === 'activityChanged' ? (event.payload as { stream?: string; text?: string }) : undefined; + if (payload?.stream === stream) { + text += payload.text; + } + } + return text; +} + +function getSummary(stdout: string): string { + // Operation headers are structured events, so the first activity banner starts the end-of-run summary. + return stdout.slice(stdout.indexOf('==[ ')); +} + +describe('phased request summary', () => { + it('reports the native summary tables and duration line after a cold build', async () => { + const fixture: ITestRoutingFixture = createFixture(); + try { + const client: TestPhasedRequestClient = new TestPhasedRequestClient(); + await new PhasedRequestRouter(fixture.session).executeAsync( + createRequest('cold', OPERATION_B), + client + ); + const stdout: string = getActivity(client, 'stdout'); + const summary: string = getSummary(stdout); + expect(summary).toContain('==[ SUCCESS: 2 operations ]=='); + expect(summary).toContain('These operations completed successfully:'); + expect(summary).toContain(` ${OPERATION_A}`); + expect(summary).toContain(` ${OPERATION_B}`); + expect(summary).not.toContain(OPERATION_C); + expect(stdout).toMatch(DURATION_LINE); + expect(stdout.indexOf('==[ SUCCESS')).toBeLessThan(stdout.search(DURATION_LINE)); + expect(client.writes[client.writes.length - 1].result).toBeDefined(); + } finally { + await fixture.session[Symbol.asyncDispose](); + } + }); + + it('reports up-to-date operations and the duration line for a warm no-op', async () => { + const fixture: ITestRoutingFixture = createFixture(); + try { + const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); + await router.executeAsync(createRequest('cold', OPERATION_B), new TestPhasedRequestClient()); + // Simulate the incremental plugin disabling every operation whose inputs did not change. + fixture.graph.hooks.configureIteration.tap( + 'test', + (records: ReadonlyMap) => { + for (const record of records.values()) { + record.enabled = false; + } + } + ); + const client: TestPhasedRequestClient = new TestPhasedRequestClient(); + const result = await router.executeAsync(createRequest('warm', OPERATION_B), client); + expect(result.scheduled).toBe(false); + const stdout: string = getActivity(client, 'stdout'); + expect(stdout).toContain('==[ SKIPPED: 2 operations ]=='); + expect(stdout).toContain('These operations were already up to date:'); + expect(stdout).toMatch(DURATION_LINE); + expect(getActivity(client, 'stderr')).toBe(''); + } finally { + await fixture.session[Symbol.asyncDispose](); + } + }); + + it('reports failed and blocked operations and the duration line for a failing build', async () => { + const fixture: ITestRoutingFixture = createFixture(OperationStatus.Failure); + try { + const client: TestPhasedRequestClient = new TestPhasedRequestClient(); + const result = await new PhasedRequestRouter(fixture.session).executeAsync( + createRequest('failing', OPERATION_B), + client + ); + expect(result.exitCode).not.toBe(0); + const stdout: string = getActivity(client, 'stdout'); + expect(stdout).toContain('==[ BLOCKED: 1 operation ]=='); + expect(stdout).toContain('==[ FAILURE: 1 operation ]=='); + expect(stdout).toContain(`--[ FAILURE: ${OPERATION_A} ]--`); + expect(stdout).toMatch(DURATION_LINE); + expect(getActivity(client, 'stderr')).toContain('Operations failed.'); + } finally { + await fixture.session[Symbol.asyncDispose](); + } + }); + + it('gives each coalesced request a summary of only its own selection', async () => { + const fixture: ITestRoutingFixture = createFixture(); + try { + const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); + const clientA: TestPhasedRequestClient = new TestPhasedRequestClient('one'); + const clientC: TestPhasedRequestClient = new TestPhasedRequestClient('two'); + await Promise.all([ + router.executeAsync(createRequest('a', OPERATION_A), clientA), + router.executeAsync(createRequest('c', OPERATION_C), clientC) + ]); + expect(fixture.runners.get(OPERATION_A)?.runCount).toBe(1); + expect(fixture.runners.get(OPERATION_C)?.runCount).toBe(1); + const stdoutA: string = getSummary(getActivity(clientA, 'stdout')); + const stdoutC: string = getSummary(getActivity(clientC, 'stdout')); + expect(stdoutA).toContain('==[ SUCCESS: 1 operation ]=='); + expect(stdoutA).toContain(` ${OPERATION_A}`); + expect(stdoutA).not.toContain(OPERATION_C); + expect(stdoutC).toContain('==[ SUCCESS: 1 operation ]=='); + expect(stdoutC).toContain(` ${OPERATION_C}`); + expect(stdoutC).not.toContain(OPERATION_A); + expect(stdoutA).toMatch(DURATION_LINE); + expect(stdoutC).toMatch(DURATION_LINE); + } finally { + await fixture.session[Symbol.asyncDispose](); + } + }); +}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 352a7ae512..974ead5616 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -174,6 +174,7 @@ export type { export { type IOperationOptions, type OperationEnabledState, Operation } from './logic/operations/Operation'; export { type IParallelismScalar, type Parallelism } from './logic/operations/ParseParallelism'; export { OperationStatus } from './logic/operations/OperationStatus'; +export { _printOperationStatus } from './logic/operations/OperationResultSummarizerPlugin'; export { PhasedCommandEngine, type IPhasedCommandEngine, From 7765788011cdb582fb3da8b2556ce37332d7decb Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 19:17:54 -0700 Subject: [PATCH 2/4] [rush-daemon] Fix summary test typing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/PhasedRequestSummary.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts b/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts index 81aab099e7..e0156d9240 100644 --- a/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts +++ b/libraries/rush-daemon/src/test/PhasedRequestSummary.test.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { IConfigurableOperation, Operation } from '@microsoft/rush-lib'; import { OperationStatus } from '@microsoft/rush-lib'; import type { IDaemonPhasedOperationSelection, IDaemonPhasedRequest } from '@rushstack/rush-daemon-protocol'; @@ -90,14 +89,11 @@ describe('phased request summary', () => { const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); await router.executeAsync(createRequest('cold', OPERATION_B), new TestPhasedRequestClient()); // Simulate the incremental plugin disabling every operation whose inputs did not change. - fixture.graph.hooks.configureIteration.tap( - 'test', - (records: ReadonlyMap) => { - for (const record of records.values()) { - record.enabled = false; - } + fixture.graph.hooks.configureIteration.tap('test', (records) => { + for (const record of records.values()) { + record.enabled = false; } - ); + }); const client: TestPhasedRequestClient = new TestPhasedRequestClient(); const result = await router.executeAsync(createRequest('warm', OPERATION_B), client); expect(result.scheduled).toBe(false); From 47ecfe15c137fc136621ebc6fdef9c7e31d8aa23 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 19:37:08 -0700 Subject: [PATCH 3/4] [rush-sdk] Update the export snapshot for _printOperationStatus Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 489e2f4841..3f9c4f019f 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -69,6 +69,7 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', + '_printOperationStatus', '_rushSdk_loadInternalModule', 'captureProjectConfigurationFingerprintAsync', 'captureWorkspaceInputFingerprintAsync', From f7ec178a397fbe851c315e4490db5938fef0630b Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 24 Sep 2026 11:09:26 -0700 Subject: [PATCH 4/4] [rush-daemon] Honor the request warnings policy in the operation summary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../rush-daemon/src/PhasedRequestRouter.ts | 3 +- .../rush-daemon/src/PhasedRequestSummary.ts | 18 ++++++++++-- .../src/test/PhasedRequestSummary.test.ts | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/libraries/rush-daemon/src/PhasedRequestRouter.ts b/libraries/rush-daemon/src/PhasedRequestRouter.ts index 67d0d18c0e..9469af22a7 100644 --- a/libraries/rush-daemon/src/PhasedRequestRouter.ts +++ b/libraries/rush-daemon/src/PhasedRequestRouter.ts @@ -552,7 +552,8 @@ class PhasedRequestBatchCoordinator { elapsedMs: performance.now() - entry.startTimeMs, executionError, graph: this.#graph, - sink: entry.requestSink + sink: entry.requestSink, + warningsAllowedByEnvironment: entry.warningsAllowedByEnvironment }); } try { diff --git a/libraries/rush-daemon/src/PhasedRequestSummary.ts b/libraries/rush-daemon/src/PhasedRequestSummary.ts index 3ee3c79b73..ed0628d60b 100644 --- a/libraries/rush-daemon/src/PhasedRequestSummary.ts +++ b/libraries/rush-daemon/src/PhasedRequestSummary.ts @@ -36,6 +36,8 @@ export interface IWritePhasedRequestSummaryOptions { readonly executionError: unknown; readonly graph: IOperationGraph; readonly sink: IPhasedRequestSummarySink; + /** Whether the request environment allows warnings in a successful build (`RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD`). */ + readonly warningsAllowedByEnvironment: boolean; } /** @@ -90,7 +92,10 @@ export function writePhasedRequestSummary(options: IWritePhasedRequestSummaryOpt if (executionError === undefined) { const operationResults: ReadonlyMap = collectSummaryResults(options); - _printOperationStatus(terminal, { operationResults, status: getSummaryStatus(operationResults) }); + _printOperationStatus(terminal, { + operationResults, + status: getSummaryStatus(operationResults, options.warningsAllowedByEnvironment) + }); terminal.writeLine(`rush ${commandName} (${duration})`); } else { terminal.writeErrorLine(`rush ${commandName} - Errors! (${duration})`); @@ -138,7 +143,10 @@ function createUpToDateResult(previous: IOperationExecutionResult): IOperationEx return upToDate as IOperationExecutionResult; } -function getSummaryStatus(results: ReadonlyMap): OperationStatus { +function getSummaryStatus( + results: ReadonlyMap, + warningsAllowedByEnvironment: boolean +): OperationStatus { let status: OperationStatus = OperationStatus.Success; for (const [operation, result] of results) { switch (result.status) { @@ -149,7 +157,11 @@ function getSummaryStatus(results: ReadonlyMap { } }); + it('honors the request warnings policy in the summary verdict', async () => { + for (const [allowWarnings, expectedVerdict] of [ + ['0', 'Operations succeeded with warnings.'], + ['1', ''] + ] as const) { + const fixture: ITestRoutingFixture = createFixture(OperationStatus.SuccessWithWarning); + try { + const client: TestPhasedRequestClient = new TestPhasedRequestClient(); + await new PhasedRequestRouter(fixture.session).executeAsync( + { + ...createRequest('warning', OPERATION_A), + environment: { RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD: allowWarnings } + }, + client + ); + expect(getActivity(client, 'stdout')).toContain('==[ SUCCESS WITH WARNINGS: 1 operation ]=='); + expect(getActivity(client, 'stdout')).toMatch(DURATION_LINE); + const stderr: string = getActivity(client, 'stderr'); + if (expectedVerdict) { + expect(stderr).toContain(expectedVerdict); + } else { + expect(stderr).not.toContain('Operations succeeded with warnings.'); + } + } finally { + await fixture.session[Symbol.asyncDispose](); + } + } + }); + it('gives each coalesced request a summary of only its own selection', async () => { const fixture: ITestRoutingFixture = createFixture(); try {