Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Expand Up @@ -434,4 +434,90 @@ 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<unknown>[] = 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('shadow parity stdout');
terminal.writeStderrLine('shadow parity stderr');
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.getAllOutput()).toEqual(plainWritable.getAllOutput());
expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false);
});
});
123 changes: 121 additions & 2 deletions libraries/rush-lib/src/pluginFramework/RushSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,11 +48,16 @@ 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<RushSession['getReporter']> }
| 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', () => {
Expand Down Expand Up @@ -223,6 +231,100 @@ 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('excludes non-public plugin envelopes from the shadow telemetry projection', () => {
const sink: CapturingSink = new CapturingSink();
const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' });
Expand All @@ -235,11 +337,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}`]);
});
});
22 changes: 13 additions & 9 deletions libraries/rush-lib/src/pluginFramework/RushSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -100,7 +101,7 @@ interface IRushSessionReportingState {
interface IRushSessionShadowEventObserver {
ingest<TPayload>(event: IReporterEmitEventInput<TPayload>, eventId: string): void;
buildTelemetryAggregate(): ITelemetryAggregate;
resolveExitStatus(): IRushExitStatus;
resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus;
correlateError(error: unknown, diagnosticId: string): void;
isErrorRepresented(error: unknown): boolean;
}
Expand Down Expand Up @@ -182,7 +183,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve
const hasOperationFailure: boolean = [...operationStatuses.values()].some(
(status) => status === 'failure' || status === 'aborted'
);
derivedExitStatus = resolveExitStatus({
derivedExitStatus = resolveRushExitStatus({
hasFailures: hasUnscopedFailure || hasOperationFailure
});
};
Expand Down Expand Up @@ -234,15 +235,15 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve
succeeded: boolean;
exitCode: number;
};
derivedExitStatus = resolveExitStatus({
derivedExitStatus = resolveRushExitStatus({
hasFailures: !succeeded || exitCode !== 0
});
break;
}
case 'commandCompleted':
case 'sessionCompleted': {
const { exitCode } = envelope.payload as { exitCode: number };
derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 });
derivedExitStatus = resolveRushExitStatus({ hasFailures: exitCode !== 0 });
break;
}
default:
Expand All @@ -262,8 +263,8 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve
return telemetrySubscriber.buildAggregate();
},

resolveExitStatus(): IRushExitStatus {
return derivedExitStatus;
resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus {
return resolveRushExitStatus({ hasFailures: derivedExitStatus.exitCode !== 0, ...options });
},

correlateError(error: unknown, diagnosticId: string): void {
Expand Down Expand Up @@ -464,8 +465,11 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe
*
* @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);
}

/**
Expand Down