diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index bb5e2c7b53..131026ce7e 100644 --- a/build-tests/rush-package-manager-integration-test/README.md +++ b/build-tests/rush-package-manager-integration-test/README.md @@ -14,6 +14,11 @@ These tests ensure the tar 7.x upgrade works correctly with these workflows. The test suite is written in TypeScript using `@rushstack/node-core-library` for cross-platform compatibility. +### testLinkIdentity.ts +Verifies local dependency links through physical and aliased repository paths, while rejecting wrong +and missing targets. Both sides of the target comparison use native-backed realpath resolution so +Windows short-name aliases do not cause false failures. + ### testNpmMode.ts Tests Rush npm mode by: - Initializing a Rush repo with `npmVersion` configured diff --git a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts index 2ef4806115..322187ca2d 100644 --- a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts +++ b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts @@ -1,6 +1,7 @@ // 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/promises'; import * as path from 'node:path'; import type * as child_process from 'node:child_process'; @@ -150,8 +151,8 @@ export class TestHelper { // Verify symlinks resolve correctly for local dependencies if (dep.startsWith('test-project-')) { - const depRealPath: string = await FileSystem.getRealPathAsync(depPath); - const expectedRealPath: string = path.join(testRepoPath, 'projects', dep); + const depRealPath: string = await fs.realpath(depPath); + const expectedRealPath: string = await fs.realpath(path.join(testRepoPath, 'projects', dep)); if (depRealPath !== expectedRealPath) { throw new Error( `ERROR: Symlink for ${dep} does not resolve correctly!\n` + diff --git a/build-tests/rush-package-manager-integration-test/src/runTests.ts b/build-tests/rush-package-manager-integration-test/src/runTests.ts index e531c6251b..62c7379f34 100644 --- a/build-tests/rush-package-manager-integration-test/src/runTests.ts +++ b/build-tests/rush-package-manager-integration-test/src/runTests.ts @@ -5,6 +5,7 @@ import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal'; import { testNpmModeAsync } from './testNpmMode'; import { testYarnModeAsync } from './testYarnMode'; +import { testLinkIdentityAsync } from './testLinkIdentity'; /** * Main test runner that executes all package manager integration tests @@ -31,6 +32,16 @@ async function runTestsAsync(): Promise { let testsFailed: number = 0; const failedTests: string[] = []; + try { + await testLinkIdentityAsync(terminal); + testsPassed++; + } catch (error) { + testsFailed++; + failedTests.push('Local dependency link identity'); + terminal.writeErrorLine('Local dependency link identity checks FAILED'); + terminal.writeErrorLine(String(error)); + } + // Run npm mode test terminal.writeLine('=========================================='); terminal.writeLine('Running NPM mode test...'); diff --git a/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts b/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts new file mode 100644 index 0000000000..ee3c4a5ab8 --- /dev/null +++ b/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ITerminal } from '@rushstack/terminal'; + +import { TestHelper } from './TestHelper'; + +export async function testLinkIdentityAsync(terminal: ITerminal): Promise { + const folder: string = await fs.mkdtemp(path.join(os.tmpdir(), 'rush-link-identity-')); + try { + const repoPath: string = path.join(folder, 'repo'); + await fs.mkdir(path.join(repoPath, 'projects', 'test-project-a'), { recursive: true }); + await fs.mkdir(path.join(repoPath, 'projects', 'wrong-target'), { recursive: true }); + await fs.mkdir(path.join(repoPath, 'projects', 'test-project-b', 'node_modules'), { recursive: true }); + + const physicalRepoPath: string = await fs.realpath(repoPath); + const aliasPath: string = path.join(folder, 'repo-alias'); + const dependencyPath: string = path.join( + physicalRepoPath, + 'projects', + 'test-project-b', + 'node_modules', + 'test-project-a' + ); + const linkType: 'junction' | 'dir' = process.platform === 'win32' ? 'junction' : 'dir'; + await fs.symlink(physicalRepoPath, aliasPath, linkType); + await fs.symlink(path.join(physicalRepoPath, 'projects', 'test-project-a'), dependencyPath, linkType); + + const helper: TestHelper = new TestHelper(terminal); + await helper.verifyDependenciesAsync(physicalRepoPath, 'test-project-b', ['test-project-a']); + await helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']); + + await fs.rm(dependencyPath, { recursive: true, force: true }); + await fs.symlink(path.join(physicalRepoPath, 'projects', 'wrong-target'), dependencyPath, linkType); + await assert.rejects( + helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']), + /does not resolve correctly/ + ); + + await fs.rm(dependencyPath, { recursive: true, force: true }); + await assert.rejects( + helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']), + /not found/ + ); + terminal.writeLine('Physical and aliased dependency links verified; wrong and missing targets rejected.'); + } finally { + await fs.rm(folder, { recursive: true, force: true }); + } +} diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json new file mode 100644 index 0000000000..a51b0ff897 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Complete shadow reporter parity coverage for event identity, telemetry privacy, exit status, repeated operation phases, and unchanged legacy output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json b/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json new file mode 100644 index 0000000000..6c34349ec5 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Connect real watch cancellation to the persistent shadow exit-status observer without changing legacy process status, and verify raw stdout/stderr chunk parity.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json b/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json new file mode 100644 index 0000000000..427d87a9dd --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Capture successful Git setup output in the real watch regression fixture so Windows line-ending notices do not mark the surrounding test operation as warned. Preserve nonzero Git failures and all native watch assertions.", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json b/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json new file mode 100644 index 0000000000..371011e5a7 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Resolve the command working directory to its physical path so watch input snapshots work with Windows short names and directory aliases. Preserve real watch cancellation and watcher cleanup coverage for both legacy and shadow reporting.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index 4205016018..70ed94b386 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -14,6 +14,17 @@ The original parser message is retained in the diagnostic's local-sensitive `mes rendering and exit codes remain unchanged. Operation registration observes the final iteration configuration, so unchanged watch operations do not produce visible shadow registration or status events. +## Shadow parity + +Rush's shadow session observer records the selected phased action's real cancellation state at completion. +A gracefully stopped watch command therefore derives the existing logical `cancelled` outcome on subsequent +observations, even when native Rush returns normally with process exit code 0. Legacy completion payloads and +binary telemetry results continue to describe that native exit; shadow reporting does not change process status. +The recorded cancellation state is reset when a new command starts. + +Operation output parity tests compare raw terminal chunks, including stream identity and unnormalized ANSI +text, as well as the actual bytes on each stdout/stderr stream. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 2b0e40aec0..d4d92a5ef4 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as fs from 'node:fs'; import * as path from 'node:path'; import { @@ -70,7 +71,8 @@ import { _getRushSessionDerivedExitStatus, _getRushSessionLifecycleEmitter, _getRushSessionReporterSourceVersion, - _isRushSessionErrorRepresented + _isRushSessionErrorRepresented, + _setRushSessionExitStatusOptions } from '../pluginFramework/RushSession'; /** @@ -390,7 +392,8 @@ export class RushCommandLineParser extends CommandLineParser { #normalizeOptions(options: Partial): IRushCommandLineParserOptions { return { - cwd: options.cwd || process.cwd(), + // Git reports physical paths, including when cwd contains a Windows short name or directory alias. + cwd: fs.realpathSync.native(options.cwd || process.cwd()), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], reporter: options.reporter, @@ -716,6 +719,12 @@ export class RushCommandLineParser extends CommandLineParser { } this.#reporterCompletionEmitted = true; + _setRushSessionExitStatusOptions(this.rushSession, { + cancelled: + this.selectedAction instanceof PhasedScriptAction && + this.selectedAction.sessionAbortController.signal.aborted + }); + const commandName: string | undefined = this.selectedAction?.actionName; if (commandName && this.#commandLifecycleEmitter) { const durationMs: number | undefined = diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 27f3d7951d..bb21a68554 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -1,9 +1,11 @@ // 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 fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { once } from 'node:events'; import { JsonFile } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSink, IRushDiagnostic } from '@rushstack/rush-reporter'; @@ -12,9 +14,13 @@ import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { IRushConfigurationJson } from '../../api/RushConfiguration'; import { _getRushSessionDerivedExitStatus, + _getRushSessionTelemetryAggregate, _isRushSessionErrorRepresented } from '../../pluginFramework/RushSession'; import { RushCommandLineParser } from '../RushCommandLineParser'; +import { PhasedScriptAction } from '../scriptActions/PhasedScriptAction'; +import { FlagFile } from '../../api/FlagFile'; +import { RushConstants } from '../../logic/RushConstants'; class CapturingReporterSink implements IReporterEventSink { public readonly events: IReporterEmitEventInput[] = []; @@ -323,4 +329,130 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(visibleErrors[1]).toEqual(visibleErrors[0]); }); + + it.each([ + { reporting: false, useAlias: false }, + { reporting: true, useAlias: false }, + { reporting: false, useAlias: true }, + { reporting: true, useAlias: true } + ])( + 'observes a real watch cancellation without changing legacy exit (shadow: $reporting, alias: $useAlias)', + async ({ reporting, useAlias }) => { + const repoPath: string = await copyRepositoryAsync(); + JsonFile.save( + { + commands: [ + { + commandKind: 'bulk', + name: 'watch-test', + summary: 'Watch cancellation fixture', + watchForChanges: true, + enableParallelism: false, + disableBuildCache: true, + safeForSimultaneousRushProcesses: true + } + ] + }, + path.join(repoPath, 'common/config/rush/command-line.json') + ); + JsonFile.save({}, path.join(repoPath, 'common/config/rush/npm-shrinkwrap.json')); + for (const name of ['a', 'b']) { + JsonFile.save( + { name, version: '1.0.0', scripts: { 'watch-test': 'node watch-test.js' } }, + path.join(repoPath, name, 'package.json') + ); + await fs.promises.writeFile( + path.join(repoPath, name, 'watch-test.js'), + 'process.stdout.write("watch child output\\n");\n' + ); + } + // Capture successful fixture setup diagnostics; failed Git commands still throw with their stderr. + execFileSync('git', ['init', '--quiet'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Rush test', + '-c', + 'user.email=rush-test@example.com', + '-c', + 'commit.gpgSign=false', + 'commit', + '--quiet', + '-m', + 'Initialize watch fixture' + ], + { cwd: repoPath, stdio: 'pipe' } + ); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const watchSpy: jest.SpyInstance = jest.spyOn(fs, 'watch'); + const cwd: string = useAlias ? path.join(path.dirname(repoPath), 'repo-alias') : repoPath; + if (useAlias) { + await fs.promises.symlink( + await fs.promises.realpath(repoPath), + cwd, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd, + reporter: reporting ? { eventSink: sink, sessionId: 'real-watch-cancellation' } : undefined + }); + await new FlagFile( + parser.rushConfiguration.defaultSubspace.getSubspaceTempFolderPath(), + RushConstants.lastLinkFlagFilename, + {} + ).createAsync(); + const action = parser.getAction('watch-test'); + if (!(action instanceof PhasedScriptAction)) { + throw new Error('Expected the production phased watch action'); + } + let reachedWatchIdle: boolean = false; + let closedWatchers: Promise[] = []; + parser.rushSession.hooks.runPhasedCommand.for('watch-test').tap('CancelRealWatch', (command) => { + command.hooks.onGraphCreatedAsync.tap('CancelRealWatch', (graph) => { + graph.hooks.onIdle.tap({ name: 'CancelRealWatch', stage: Number.MAX_SAFE_INTEGER }, () => { + reachedWatchIdle = true; + closedWatchers = watchSpy.mock.results.map(({ value }) => once(value as fs.FSWatcher, 'close')); + action.sessionAbortController.abort(); + }); + }); + }); + const execution: Promise = parser.executeAsync(['watch-test', '--verbose']); + try { + await expect(execution).resolves.toBe(true); + await Promise.all(closedWatchers); + expect(reachedWatchIdle).toBe(true); + expect(parser.cwd).toBe(await fs.promises.realpath(repoPath)); + expect(watchSpy.mock.calls.length).toBeGreaterThan(0); + expect(action.sessionAbortController.signal.aborted).toBe(true); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual( + reporting ? { exitCode: 1, outcome: 'cancelled' } : undefined + ); + if (reporting) { + expect(sink.events.filter(isCompletion).map(({ payload }) => payload)).toEqual([ + expect.objectContaining({ succeeded: true, exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }) + ]); + expect(_getRushSessionTelemetryAggregate(parser.rushSession)).toMatchObject({ + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 2 } + }); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + } + } finally { + action.sessionAbortController.abort(); + await execution; + await Promise.all(closedWatchers); + } + } + ); }); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 0d4a3fb280..fe0c84bab5 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -// Deterministic Stopwatch timing, matching OperationGraph.test.ts +// Exercise the color-preserving terminal pipeline on every test platform. jest.mock('@rushstack/terminal', () => { const originalModule = jest.requireActual('@rushstack/terminal'); return { @@ -35,7 +35,12 @@ jest.mock('../ProjectLogWritable', () => { }); import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; -import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; +import { + MockWritable, + StringBufferTerminalProvider, + TerminalChunkKind, + type ITerminalChunk +} from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -230,7 +235,7 @@ describe('OperationGraph event sink (dual-emit)', () => { tappedGraph.eventSink = new RecordingSink(); await tappedGraph.executeAsync({}); - expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(tappedWritable.chunks).toEqual(plainWritable.chunks); }); it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { @@ -279,7 +284,7 @@ describe('OperationGraph event sink (dual-emit)', () => { }) ); expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); - expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(mockWritable.chunks).toEqual(plainWritable.chunks); }); it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { @@ -532,4 +537,109 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(countEvents('operationRegistered')).toBe(registrationCount); expect(countEvents('operationStatusChanged')).toBe(statusCount); }); + + it('keeps project x phase identities stable across repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + }); + const compilePhase: IPhase = { + ...mockPhase, + name: '_phase:compile', + logFilenameIdentifier: '_phase_compile' + }; + const testPhase: IPhase = { + ...mockPhase, + name: '_phase:test', + logFilenameIdentifier: '_phase_test' + }; + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation( + '@scope/project compile', + new MockOperationRunner('@scope/project (_phase:compile)'), + compilePhase, + '@scope/project' + ), + createOperation( + '@scope/project test', + new MockOperationRunner('@scope/project (_phase:test)'), + testPhase, + '@scope/project' + ) + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + const registrations: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' + ); + expect(registrations.map(({ scope }) => scope?.operationId)).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { + expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); + expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); + } + }); + + it('leaves stdout, stderr, and StreamCollator rendering byte-identical with shadow reporting', async () => { + const createOutputRunner = (): MockOperationRunner => + new MockOperationRunner('output', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('\u001b[32mshadow parity stdout\u001b[0m'); + terminal.writeStderrLine('\u001b[31mshadow parity stderr\u001b[0m'); + return OperationStatus.Success; + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + }); + const shadowWritable: MockWritable = new MockWritable(); + const shadowGraph: OperationGraph = new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(shadowWritable, false) + ); + attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + await shadowGraph.executeAsync({}); + expect(shadowWritable.chunks).toEqual(plainWritable.chunks); + for (const [kind, text] of [ + [TerminalChunkKind.Stdout, '\u001b[32mshadow parity stdout\u001b[0m'], + [TerminalChunkKind.Stderr, '\u001b[31mshadow parity stderr\u001b[0m'] + ] as const) { + const plainBytes: Buffer = Buffer.from( + plainWritable.chunks + .filter((chunk) => chunk.kind === kind) + .map((chunk) => chunk.text) + .join('') + ); + const shadowBytes: Buffer = Buffer.from( + shadowWritable.chunks + .filter((chunk) => chunk.kind === kind) + .map((chunk) => chunk.text) + .join('') + ); + expect(plainBytes.includes(Buffer.from(text))).toBe(true); + expect(shadowBytes).toEqual(plainBytes); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index c4287f8d58..f771fd1f5e 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -7,7 +7,10 @@ import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, - IReporterEventSink + IReporterEventSink, + IResolveExitStatusFromEventsOptions, + IRushExitStatus, + LifecycleEmitter } from '@rushstack/rush-reporter'; import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; @@ -21,6 +24,7 @@ import { _getRushSessionLifecycleEmitter, _getRushSessionTelemetryAggregate, _isRushSessionErrorRepresented, + _setRushSessionExitStatusOptions, type IRushSessionReporterOptions, RushSession } from './RushSession'; @@ -45,11 +49,15 @@ function createSession(reporter?: IRushSessionReporterOptions): RushSession { describe(RushSession.name, () => { it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { const session: RushSession = createSession(); + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: os.tmpdir() }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + { reporter?: ReturnType } | undefined; expect(session.getReporter()).toBeUndefined(); expect(session.getScopedLogger()).toBeUndefined(); expect(session.getLogger('legacy')).toBeDefined(); expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + expect(action?.reporter).toBeUndefined(); }); it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { @@ -153,8 +161,7 @@ describe(RushSession.name, () => { reporter: { eventSink: sink, sessionId: 'session-4' } }); const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as - | { reporter?: ReturnType } - | undefined; + { reporter?: ReturnType } | undefined; expect(action?.reporter).toBeDefined(); action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); @@ -223,6 +230,129 @@ describe(RushSession.name, () => { }); }); + it('preserves event order, correlation, session identity, and trusted producer identity', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'ordered-session' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + })); + const sessionEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + const commandEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { + commandName: 'build' + })!; + const diagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED'); + const error: Error = new Error('represented'); + + sessionEmitter.emitSessionStarted({ rushVersion: Rush.version }); + commandEmitter.emitCommandStarted({ commandName: 'build' }); + pluginSession.getReporter({ commandName: 'build' })!.emitMessage({ + severity: 'info', + text: 'plugin message' + }); + commandEmitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + commandEmitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + commandEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 1 }); + sessionEmitter.emitSessionCompleted({ exitCode: 1 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'messageEmitted', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(new Set(sink.inputs.map(({ sessionId }) => sessionId))).toEqual(new Set(['ordered-session'])); + expect(sink.inputs[0].source).toMatchObject({ + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }); + expect(sink.inputs[2].source).toEqual({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }); + expect(sink.inputs[3].payload).toMatchObject({ diagnosticId: diagnostic.diagnosticId }); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + }); + + it('derives legacy-compatible exit status for success, warnings, failures, cancellation, and errors', () => { + const derive = ( + emitEvents: (emitter: LifecycleEmitter) => void, + options?: IResolveExitStatusFromEventsOptions + ): IRushExitStatus => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'exit-session' + }); + emitEvents(_getRushSessionLifecycleEmitter(session, { commandName: 'build' })!); + return _getRushSessionDerivedExitStatus(session, options)!; + }; + + expect( + derive((emitter) => { + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED', { severity: 'warning' })); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'failure' }); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + + expect(derive(() => {}, { cancelled: true })).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + for (const code of ['RUSH_CONFIG_INVALID_JSON', 'RUSH_INTERNAL_UNEXPECTED'] as const) { + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic(code)); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + } + }); + + it('retains command cancellation through completion and scoped observations, but not the next command', () => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'cancelled-command' + }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/plugin', + packageVersion: '1.0.0' + })); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build' }); + _setRushSessionExitStatusOptions(session, { cancelled: true }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + expect(_getRushSessionDerivedExitStatus(pluginSession)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + expect(_getRushSessionDerivedExitStatus(session, { cancelled: false })).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(_getRushSessionDerivedExitStatus(session, { signal: 'SIGTERM' })?.outcome).toBe('signal'); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + emitter.emitCommandStarted({ commandName: 'build' }); + expect(_getRushSessionDerivedExitStatus(pluginSession)).toEqual({ exitCode: 0, outcome: 'succeeded' }); + }); + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { const sink: CapturingSink = new CapturingSink(); const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); @@ -235,11 +365,28 @@ describe(RushSession.name, () => { severity: 'info', text: '/local/private/path' }); - _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + pluginSession.getReporter()!.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: 'private-secret-token', privacy: 'secret' } + } + }) + ); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=public-envelope-secret'] }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); const aggregate = _getRushSessionTelemetryAggregate(session)!; expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(JSON.stringify(aggregate)).not.toContain('private-secret-token'); + expect(JSON.stringify(aggregate)).not.toContain('--auth-token=public-envelope-secret'); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0 + }); expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index fa9771ccb0..848e128059 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -8,12 +8,13 @@ import { RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, - resolveExitStatus, + resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IResolveExitStatusFromEventsOptions, type IRushExitStatus, type ITelemetryAggregate, type IScopedLogger, @@ -100,7 +101,8 @@ interface IRushSessionReportingState { interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; - resolveExitStatus(): IRushExitStatus; + setExitStatusOptions(options: IResolveExitStatusFromEventsOptions): void; + resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; correlateError(error: unknown, diagnosticId: string): void; isErrorRepresented(error: unknown): boolean; } @@ -176,13 +178,14 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve const operationStatuses: Map = new Map(); let sequence: number = 0; let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let commandExitStatusOptions: IResolveExitStatusFromEventsOptions = {}; let hasUnscopedFailure: boolean = false; const updateDerivedOperationStatus = (): void => { const hasOperationFailure: boolean = [...operationStatuses.values()].some( (status) => status === 'failure' || status === 'aborted' ); - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: hasUnscopedFailure || hasOperationFailure }); }; @@ -203,6 +206,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandStarted': { operationStatuses.clear(); hasUnscopedFailure = false; + commandExitStatusOptions = {}; derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; break; } @@ -234,7 +238,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve succeeded: boolean; exitCode: number; }; - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: !succeeded || exitCode !== 0 }); break; @@ -242,7 +246,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandCompleted': case 'sessionCompleted': { const { exitCode } = envelope.payload as { exitCode: number }; - derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + derivedExitStatus = resolveRushExitStatus({ hasFailures: exitCode !== 0 }); break; } default: @@ -262,8 +266,16 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve return telemetrySubscriber.buildAggregate(); }, - resolveExitStatus(): IRushExitStatus { - return derivedExitStatus; + setExitStatusOptions(options: IResolveExitStatusFromEventsOptions): void { + commandExitStatusOptions = { ...options }; + }, + + resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus { + return resolveRushExitStatus({ + hasFailures: derivedExitStatus.exitCode !== 0, + ...commandExitStatusOptions, + ...options + }); }, correlateError(error: unknown, diagnosticId: string): void { @@ -459,13 +471,29 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); } +/** + * Records real command cancellation/signal state for subsequent shadow observations. + * Legacy completion payloads and telemetry retain their authoritative process exit code. + * + * @internal + */ +export function _setRushSessionExitStatusOptions( + rushSession: RushSession, + options: IResolveExitStatusFromEventsOptions +): void { + _getSessionState(rushSession).reporting?.observer.setExitStatusOptions(options); +} + /** * Derives the shadow exit status without changing the authoritative process exit code. * * @internal */ -export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { - return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +export function _getRushSessionDerivedExitStatus( + rushSession: RushSession, + options?: IResolveExitStatusFromEventsOptions +): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(options); } /**