Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4684f6e
Add Rush reporter repository configuration
TheLarkInn Aug 28, 2026
991a481
Isolate reporter configuration test fixtures from shared cleanup
TheLarkInn Sep 7, 2026
ebbb477
Refresh R2B reporter controls onto native-private trunk
TheLarkInn Sep 9, 2026
9c40269
Refresh R3A scoped producers onto native-private trunk
TheLarkInn Sep 9, 2026
a4c8126
Refresh R3B shadow lifecycle onto native-private trunk
TheLarkInn Sep 10, 2026
9834e30
Refresh R3C parity coverage onto native-private trunk
TheLarkInn Sep 10, 2026
0749f38
Clarify the frontend reporter channel identity contract
TheLarkInn Sep 10, 2026
65ba96b
Preserve rollback flags and primary file detail defaults
TheLarkInn Sep 10, 2026
3fbddc0
Connect watch cancellation to shadow parity observation
TheLarkInn Sep 10, 2026
6d645ad
Fix shadow lifecycle error correlation and final registration
TheLarkInn Sep 10, 2026
68cab2b
Respect command ownership when consuming reporter controls
TheLarkInn Sep 10, 2026
a93c236
Merge reviewed R3B lifecycle fixes into R3C parity
TheLarkInn Sep 10, 2026
7abfad2
Merge final R2B controls into fixed R3A session handoff
TheLarkInn Sep 10, 2026
5c2a107
Merge final R3A parent into reviewed R3B lifecycle
TheLarkInn Sep 10, 2026
44c0e99
Merge final R3B parent into reviewed R3C parity
TheLarkInn Sep 10, 2026
c857a9b
Refresh R2B reporter controls onto native-private trunk
TheLarkInn Sep 9, 2026
c4c05af
Preserve rollback flags and primary file detail defaults
TheLarkInn Sep 10, 2026
55e4328
Respect command ownership when consuming reporter controls
TheLarkInn Sep 10, 2026
d71c2ff
Refresh R3A scoped producers onto native-private trunk
TheLarkInn Sep 9, 2026
47d4f36
Clarify the frontend reporter channel identity contract
TheLarkInn Sep 10, 2026
2fc47f5
Refresh R3B shadow lifecycle onto native-private trunk
TheLarkInn Sep 10, 2026
852b04d
Fix shadow lifecycle error correlation and final registration
TheLarkInn Sep 10, 2026
188e673
Merge post-R2A native R3B into reviewed R3C parity
TheLarkInn Sep 10, 2026
1a13d64
Normalize Rush cwd before analyzing repository inputs
TheLarkInn Sep 10, 2026
1feb3ba
Use native realpath to expand Windows short directory names
TheLarkInn Sep 10, 2026
d672867
Capture successful Git setup diagnostics in watch regression tests
TheLarkInn Sep 10, 2026
08cd910
Compare physical dependency targets in package manager tests
TheLarkInn Sep 10, 2026
ba590f5
Reconcile squash-landed Reporter ancestry for R3C
TheLarkInn Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build-tests/rush-package-manager-integration-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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` +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,16 @@ async function runTestsAsync(): Promise<void> {
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...');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
11 changes: 11 additions & 0 deletions libraries/reporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions libraries/rush-lib/src/cli/RushCommandLineParser.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -70,7 +71,8 @@ import {
_getRushSessionDerivedExitStatus,
_getRushSessionLifecycleEmitter,
_getRushSessionReporterSourceVersion,
_isRushSessionErrorRepresented
_isRushSessionErrorRepresented,
_setRushSessionExitStatusOptions
} from '../pluginFramework/RushSession';

/**
Expand Down Expand Up @@ -390,7 +392,8 @@ export class RushCommandLineParser extends CommandLineParser {

#normalizeOptions(options: Partial<IRushCommandLineParserOptions>): 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,
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<unknown>[] = [];
Expand Down Expand Up @@ -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<unknown>[] = [];
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<boolean> = 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);
}
}
);
});
Loading