diff --git a/README.md b/README.md index 9cbcb7c3317..5a304b7782b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/apps/playwright-browser-tunnel](./apps/playwright-browser-tunnel/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel.svg)](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel) | [changelog](./apps/playwright-browser-tunnel/CHANGELOG.md) | [@rushstack/playwright-browser-tunnel](https://www.npmjs.com/package/@rushstack/playwright-browser-tunnel) | | [/apps/rundown](./apps/rundown/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frundown.svg)](https://badge.fury.io/js/%40rushstack%2Frundown) | [changelog](./apps/rundown/CHANGELOG.md) | [@rushstack/rundown](https://www.npmjs.com/package/@rushstack/rundown) | | [/apps/rush](./apps/rush/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush.svg)](https://badge.fury.io/js/%40microsoft%2Frush) | [changelog](./apps/rush/CHANGELOG.md) | [@microsoft/rush](https://www.npmjs.com/package/@microsoft/rush) | +| [/apps/rush-cli-client](./apps/rush-cli-client/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-cli-client.svg)](https://badge.fury.io/js/%40rushstack%2Frush-cli-client) | [changelog](./apps/rush-cli-client/CHANGELOG.md) | [@rushstack/rush-cli-client](https://www.npmjs.com/package/@rushstack/rush-cli-client) | | [/apps/rush-mcp-server](./apps/rush-mcp-server/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fmcp-server.svg)](https://badge.fury.io/js/%40rushstack%2Fmcp-server) | [changelog](./apps/rush-mcp-server/CHANGELOG.md) | [@rushstack/mcp-server](https://www.npmjs.com/package/@rushstack/mcp-server) | | [/apps/trace-import](./apps/trace-import/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Ftrace-import.svg)](https://badge.fury.io/js/%40rushstack%2Ftrace-import) | [changelog](./apps/trace-import/CHANGELOG.md) | [@rushstack/trace-import](https://www.npmjs.com/package/@rushstack/trace-import) | | [/apps/zipsync](./apps/zipsync/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fzipsync.svg)](https://badge.fury.io/js/%40rushstack%2Fzipsync) | [changelog](./apps/zipsync/CHANGELOG.md) | [@rushstack/zipsync](https://www.npmjs.com/package/@rushstack/zipsync) | @@ -98,6 +99,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/libraries/problem-matcher](./libraries/problem-matcher/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) | | [/libraries/reporter](./libraries/reporter/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-reporter.svg)](https://badge.fury.io/js/%40rushstack%2Frush-reporter) | [changelog](./libraries/reporter/CHANGELOG.md) | [@rushstack/rush-reporter](https://www.npmjs.com/package/@rushstack/rush-reporter) | | [/libraries/rig-package](./libraries/rig-package/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) | +| [/libraries/rush-client-core](./libraries/rush-client-core/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-client-core.svg)](https://badge.fury.io/js/%40rushstack%2Frush-client-core) | [changelog](./libraries/rush-client-core/CHANGELOG.md) | [@rushstack/rush-client-core](https://www.npmjs.com/package/@rushstack/rush-client-core) | | [/libraries/rush-daemon](./libraries/rush-daemon/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon) | [changelog](./libraries/rush-daemon/CHANGELOG.md) | [@rushstack/rush-daemon](https://www.npmjs.com/package/@rushstack/rush-daemon) | | [/libraries/rush-daemon-protocol](./libraries/rush-daemon-protocol/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol) | [changelog](./libraries/rush-daemon-protocol/CHANGELOG.md) | [@rushstack/rush-daemon-protocol](https://www.npmjs.com/package/@rushstack/rush-daemon-protocol) | | [/libraries/rush-daemon-transport](./libraries/rush-daemon-transport/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport) | [changelog](./libraries/rush-daemon-transport/CHANGELOG.md) | [@rushstack/rush-daemon-transport](https://www.npmjs.com/package/@rushstack/rush-daemon-transport) | diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index 1a9f0ef99ef..1d61e1db0ac 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -26,6 +26,7 @@ import type { import type { InternalHeftSession } from '../pluginFramework/InternalHeftSession'; import type { HeftConfiguration } from '../configuration/HeftConfiguration'; import type { LoggingManager } from '../pluginFramework/logging/LoggingManager'; +import type { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; import type { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftParameterManager } from '../pluginFramework/HeftParameterManager'; import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner'; @@ -71,9 +72,13 @@ export function initializeHeft( ): void { // Ensure that verbose is enabled on the terminal if requested. terminalProvider.verboseEnabled // should already be `true` if the `--debug` flag was provided. This is set in HeftCommandLineParser - if (heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider) { - heftConfiguration.terminalProvider.verboseEnabled = - heftConfiguration.terminalProvider.verboseEnabled || isVerbose; + if ( + heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider || + 'verboseEnabled' in heftConfiguration.terminalProvider + ) { + const terminalProvider: ConsoleTerminalProvider | HeftChildReporter = + heftConfiguration.terminalProvider as ConsoleTerminalProvider | HeftChildReporter; + terminalProvider.verboseEnabled = terminalProvider.verboseEnabled || isVerbose; } // Log some information about the execution diff --git a/apps/heft/src/cli/HeftCommandLineParser.ts b/apps/heft/src/cli/HeftCommandLineParser.ts index 0b26079c855..6b7ada39707 100644 --- a/apps/heft/src/cli/HeftCommandLineParser.ts +++ b/apps/heft/src/cli/HeftCommandLineParser.ts @@ -10,7 +10,12 @@ import { type CommandLineAction } from '@rushstack/ts-command-line'; import { InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; -import { Terminal, ConsoleTerminalProvider, type ITerminal } from '@rushstack/terminal'; +import { + Terminal, + ConsoleTerminalProvider, + type ITerminal, + type ITerminalProvider +} from '@rushstack/terminal'; import { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftConfiguration } from '../configuration/HeftConfiguration'; @@ -23,6 +28,7 @@ import type { IHeftActionOptions } from './actions/IHeftAction'; import { AliasAction } from './actions/AliasAction'; import { getToolParameterNamesFromArgs } from '../utilities/CliUtilities'; import { Constants } from '../utilities/Constants'; +import { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; /** * This interfaces specifies values for parameters that must be parsed before the CLI @@ -41,7 +47,8 @@ export class HeftCommandLineParser extends CommandLineParser { readonly #debugFlag: CommandLineFlagParameter; readonly #unmanagedFlag: CommandLineFlagParameter; readonly #debug: boolean; - readonly #terminalProvider: ConsoleTerminalProvider; + readonly #terminalProvider: ITerminalProvider; + readonly #childReporter: HeftChildReporter | undefined; readonly #loggingManager: LoggingManager; readonly #metricsCollector: MetricsCollector; readonly #heftConfiguration: HeftConfiguration; @@ -77,12 +84,22 @@ export class HeftCommandLineParser extends CommandLineParser { this.#debug = !!preInitializationArgumentValues.debug; // Enable debug and verbose logging if the "--debug" flag is set - this.#terminalProvider = new ConsoleTerminalProvider({ - debugEnabled: this.#debug, - verboseEnabled: this.#debug - }); + this.#childReporter = HeftChildReporter.tryInitialize(); + this.#terminalProvider = + this.#childReporter ?? + new ConsoleTerminalProvider({ + debugEnabled: this.#debug, + verboseEnabled: this.#debug + }); + if (this.#debug && this.#childReporter) { + this.#childReporter.debugEnabled = true; + this.#childReporter.verboseEnabled = true; + } this.globalTerminal = new Terminal(this.#terminalProvider); - this.#loggingManager = new LoggingManager({ terminalProvider: this.#terminalProvider }); + this.#loggingManager = new LoggingManager({ + terminalProvider: this.#terminalProvider, + childReporter: this.#childReporter + }); if (this.#debug) { // Enable printing stacktraces if the "--debug" flag is set this.#loggingManager.enablePrintStacks(); @@ -197,6 +214,7 @@ export class HeftCommandLineParser extends CommandLineParser { commandName, unaliasedCommandName }; + this.#childReporter?.setCommandName(commandName); await super.onExecuteAsync(); } catch (e) { await this.#reportErrorAndSetExitCodeAsync(e as Error); @@ -224,7 +242,7 @@ export class HeftCommandLineParser extends CommandLineParser { args: string[] = process.argv ): IPreInitializationArgumentValues { if (!this.#debugFlag) { - // The `this._debugFlag` parameter (the parameter itself, not its value) + // The `this.#debugFlag` parameter (the parameter itself, not its value) // has not yet been defined. Parameters need to be defined before we // try to evaluate any parameters. This is to ensure that the // `--debug` flag is defined correctly before we do this not-so-rigorous @@ -241,7 +259,11 @@ export class HeftCommandLineParser extends CommandLineParser { async #reportErrorAndSetExitCodeAsync(error: Error): Promise { if (!(error instanceof AlreadyReportedError)) { - this.globalTerminal.writeErrorLine(error.toString()); + if (this.#childReporter) { + this.#childReporter.emitDiagnostic(Constants.heftPackageName, error, 'error'); + } else { + this.globalTerminal.writeErrorLine(error.toString()); + } } if (this.#debug) { diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts new file mode 100644 index 00000000000..87885f35d2f --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import { PackageJsonLookup } from '@rushstack/node-core-library'; + +import { HeftChildReporter } from './HeftChildReporter'; + +describe(HeftChildReporter.name, () => { + it('preserves standalone behavior when no parent descriptors are present', () => { + expect(HeftChildReporter.tryInitialize({})).toBeUndefined(); + }); + + it('does not write to or close descriptors that are not pipes', () => { + const folderPath: string = fs.mkdtempSync(path.join(os.tmpdir(), 'heft-child-reporter-')); + const eventPath: string = path.join(folderPath, 'event'); + const acknowledgementPath: string = path.join(folderPath, 'ack'); + const eventFd: number = fs.openSync(eventPath, 'w+'); + const acknowledgementFd: number = fs.openSync(acknowledgementPath, 'w+'); + try { + expect( + HeftChildReporter.tryInitialize({ + _RUSH_REPORTER_CHILD_FD: String(eventFd), + _RUSH_REPORTER_CHILD_ACK_FD: String(acknowledgementFd) + }) + ).toBeUndefined(); + expect(fs.readFileSync(eventPath, 'utf8')).toBe(''); + expect(() => fs.writeSync(acknowledgementFd, 'still open')).not.toThrow(); + } finally { + fs.closeSync(eventFd); + fs.closeSync(acknowledgementFd); + fs.rmSync(folderPath, { recursive: true }); + } + }); + + it('recognizes pipe mode bits when platform predicates return false', () => { + const fsModule: typeof fs = jest.requireActual('node:fs'); + const stats: fs.Stats = fs.statSync(__filename); + stats.mode = fs.constants.S_IFIFO; + stats.isFIFO = () => false; + stats.isSocket = () => false; + const acknowledgement: Buffer = Buffer.from( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1'], + rejectedRequiredFeatures: [] + })}\n` + ); + const statSpy: jest.SpyInstance = jest.spyOn(fsModule, 'fstatSync').mockReturnValue(stats); + const packageSpy: jest.SpyInstance = jest + .spyOn(PackageJsonLookup.instance, 'tryLoadPackageJsonFor') + .mockReturnValue({ name: '@rushstack/heft', version: '1.0.0' }); + const writeSpy: jest.SpyInstance = jest.spyOn(fsModule, 'writeSync').mockReturnValue(1); + const readSpy: jest.SpyInstance = jest.spyOn(fsModule, 'readSync').mockImplementation((fd, buffer) => { + expect(fd).toBe(4); + if (!Buffer.isBuffer(buffer)) { + throw new Error('Expected the acknowledgement read buffer.'); + } + return acknowledgement.copy(buffer); + }); + const closeSpy: jest.SpyInstance = jest.spyOn(fsModule, 'closeSync').mockImplementation(() => {}); + + try { + const reporter: HeftChildReporter | undefined = HeftChildReporter.tryInitialize({ + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }); + + expect(reporter).toBeDefined(); + expect(reporter?.parentReporterName).toBe('plaintext'); + expect(writeSpy).toHaveBeenCalledWith(3, expect.stringContaining('"kind":"hello"')); + expect(closeSpy).toHaveBeenCalledWith(4); + expect(closeSpy).not.toHaveBeenCalledWith(3); + } finally { + closeSpy.mockRestore(); + readSpy.mockRestore(); + writeSpy.mockRestore(); + packageSpy.mockRestore(); + statSpy.mockRestore(); + } + }); + + it('negotiates context and emits ordered structured output and diagnostics', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const loggingManagerPath: string = require.resolve('./LoggingManager'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const { LoggingManager } = require(process.argv[2]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) { + process.stdout.write('fallback'); + process.exit(2); + } + if (reporter.parentReporterName !== 'json' || reporter.terminalWidth !== 132) process.exit(3); + reporter.setCommandName('build'); + reporter.write('visible output\\n', 0); + reporter.write('hidden verbose output\\n', 3); + const logging = new LoggingManager({ terminalProvider: reporter, childReporter: reporter }); + const logger = logging.requestScopedLogger('typescript'); + logger.emitError(new Error('structured failure')); + if (!logging.errorsHaveBeenEmitted || !logger.hasErrors) process.exit(4); + logging.resetScopedLoggerErrorsAndWarnings(); + if (logging.errorsHaveBeenEmitted || logger.hasErrors) process.exit(5); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath, loggingManagerPath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 132 + } + })}\n` + ); + } + }); + + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const records: Array> = descriptorText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records[0].kind).toBe('hello'); + expect(records.slice(1).map((record) => record.type)).toEqual(['externalOutput', 'diagnosticEmitted']); + expect(records.slice(1).map((record) => record.sequence)).toEqual([1, 2]); + expect((records[1].scope as { commandName?: string }).commandName).toBe('build'); + expect((records[1].payload as { text?: string }).text).toBe('visible output\n'); + expect((records[2].payload as { severity?: string }).severity).toBe('error'); + }); + + it('uses safe context defaults when the accepted context capability has no payload', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) process.exit(2); + if (reporter.parentReporterName !== 'plaintext' || reporter.terminalWidth !== 80) process.exit(3); + reporter.write('structured with defaults\\n', 0); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (descriptorText.split('\n').length === 2) { + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [] + })}\n` + ); + } + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(descriptorText).toContain('structured with defaults'); + }); + + it.each([0, 'wide'])( + 'falls back safely for invalid parent terminal width %p', + async (terminalWidth: unknown) => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (reporter) process.exit(2); + process.stdout.write('context fallback'); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth + } + })}\n` + ); + } + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(acknowledgementSent).toBe(true); + expect(stdout).toBe('context fallback'); + } + ); +}); diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts new file mode 100644 index 00000000000..471c053b88e --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { EOL } from 'node:os'; + +import { FileError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; + +import { Constants } from '../../utilities/Constants'; + +const CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; +const CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD' = '_RUSH_REPORTER_CHILD_ACK_FD'; +interface IProtocolVersion { + readonly major: number; + readonly minor: number; +} +const PROTOCOL_VERSION: IProtocolVersion = { major: 1, minor: 2 }; +const MAX_RECORD_BYTES: number = 1024 * 1024; +const MAX_OUTPUT_CHUNK_BYTES: number = 64 * 1024; +const CAPABILITIES: readonly string[] = ['heft-child-events-v1', 'reporter-context-v1']; +const REPORTER_NAMES: ReadonlySet = new Set(['default', 'ai', 'json', 'plaintext', 'file', 'legacy']); +const LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +interface IReporterChildContext { + readonly reporter: string; + readonly logLevel: 'quiet' | 'normal' | 'verbose' | 'debug'; + readonly color: boolean; + readonly terminalWidth: number; +} + +interface IReporterEventScope { + readonly commandName?: string; +} + +function readDescriptorFd(env: Record, name: string): number | undefined { + const raw: string | undefined = env[name]; + if (raw === undefined || !/^\d+$/.test(raw)) { + return undefined; + } + const parsed: number = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; +} + +function isReporterPipe(fd: number): boolean { + try { + const stats: fs.Stats = fs.fstatSync(fd); + // Node disables isFIFO() on Windows even when fstat reports a named pipe. + // eslint-disable-next-line no-bitwise -- Compare the file type without permission bits. + return (stats.mode & fs.constants.S_IFMT) === fs.constants.S_IFIFO || stats.isSocket(); + } catch { + return false; + } +} + +function encodeRecord(value: unknown): string { + const json: string = JSON.stringify(value); + if (Buffer.byteLength(json, 'utf8') > MAX_RECORD_BYTES) { + throw new Error(`The reporter record exceeds the ${MAX_RECORD_BYTES}-byte protocol limit.`); + } + return `${json}\n`; +} + +function chunkUtf8Text(text: string): string[] { + const chunks: string[] = []; + let offset: number = 0; + while (offset < text.length) { + let end: number = offset; + let byteLength: number = 0; + while (end < text.length) { + const codePoint: number = text.codePointAt(end)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (end > offset && byteLength + codePointBytes > MAX_OUTPUT_CHUNK_BYTES) { + break; + } + byteLength += codePointBytes; + end += codeUnits; + } + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} + +function parseAck(text: string): IReporterChildContext | undefined { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + const ack: Record = value as Record; + const version: Record | undefined = + typeof ack.protocolVersion === 'object' && ack.protocolVersion !== null + ? (ack.protocolVersion as Record) + : undefined; + const acceptedCapabilities: unknown = ack.acceptedCapabilities; + const rejectedRequiredFeatures: unknown = ack.rejectedRequiredFeatures; + if ( + ack.kind !== 'helloAck' || + version?.major !== PROTOCOL_VERSION.major || + !Array.isArray(acceptedCapabilities) || + !acceptedCapabilities.every((item: unknown) => typeof item === 'string') || + !acceptedCapabilities.includes('heft-child-events-v1') || + !Array.isArray(rejectedRequiredFeatures) || + rejectedRequiredFeatures.length > 0 + ) { + return undefined; + } + + if (!acceptedCapabilities.includes('reporter-context-v1')) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + const context: unknown = ack.context; + if (context === undefined) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + if (typeof context !== 'object' || context === null || Array.isArray(context)) { + return undefined; + } + const record: Record = context as Record; + if ( + typeof record.reporter !== 'string' || + !REPORTER_NAMES.has(record.reporter) || + typeof record.logLevel !== 'string' || + !LOG_LEVELS.has(record.logLevel) || + typeof record.color !== 'boolean' || + typeof record.terminalWidth !== 'number' || + !Number.isSafeInteger(record.terminalWidth) || + record.terminalWidth < 1 + ) { + return undefined; + } + const logLevel: IReporterChildContext['logLevel'] = + record.logLevel === 'quiet' || + record.logLevel === 'normal' || + record.logLevel === 'verbose' || + record.logLevel === 'debug' + ? record.logLevel + : 'normal'; + return { + reporter: record.reporter, + logLevel, + color: record.color, + terminalWidth: record.terminalWidth + }; +} + +/** + * Bridges Heft terminal and diagnostic output onto an inherited Rush reporter channel. + * + * @internal + */ +export class HeftChildReporter implements ITerminalProvider { + public readonly supportsColor: boolean; + public readonly eolCharacter: string = EOL; + public readonly parentReporterName: string; + public readonly terminalWidth: number; + public verboseEnabled: boolean; + public debugEnabled: boolean; + + private readonly _descriptorFd: number; + private readonly _sourceVersion: string; + private readonly _sessionId: string; + private _commandName: string | undefined; + private _sequence: number = 1; + private _nextEventId: number = 1; + + private constructor(descriptorFd: number, sourceVersion: string, context: IReporterChildContext) { + this._descriptorFd = descriptorFd; + this._sourceVersion = sourceVersion; + this._sessionId = crypto.randomUUID(); + this.parentReporterName = context.reporter; + this.terminalWidth = context.terminalWidth; + this.supportsColor = context.color; + this.verboseEnabled = context.logLevel === 'verbose' || context.logLevel === 'debug'; + this.debugEnabled = context.logLevel === 'debug'; + } + + public static tryInitialize( + env: Record = process.env + ): HeftChildReporter | undefined { + const descriptorFd: number | undefined = readDescriptorFd(env, CHILD_FD_ENV_VAR); + const ackDescriptorFd: number | undefined = readDescriptorFd(env, CHILD_ACK_FD_ENV_VAR); + delete env[CHILD_FD_ENV_VAR]; + delete env[CHILD_ACK_FD_ENV_VAR]; + if ( + descriptorFd === undefined || + ackDescriptorFd === undefined || + descriptorFd === ackDescriptorFd || + !isReporterPipe(descriptorFd) || + !isReporterPipe(ackDescriptorFd) + ) { + return undefined; + } + + const packageJson: IPackageJson | undefined = PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); + const version: string = packageJson?.version ?? 'unknown'; + let reporter: HeftChildReporter | undefined; + try { + fs.writeSync( + descriptorFd, + encodeRecord({ + kind: 'hello', + protocolVersion: PROTOCOL_VERSION, + producerVersion: `${Constants.heftPackageName} ${version}`, + capabilities: CAPABILITIES, + requiredFeatures: [] + }) + ); + + const chunks: Buffer[] = []; + let totalBytes: number = 0; + const buffer: Buffer = Buffer.allocUnsafe(4096); + for (;;) { + const byteCount: number = fs.readSync(ackDescriptorFd, buffer, 0, buffer.length, null); + if (byteCount === 0) { + break; + } + totalBytes += byteCount; + if (totalBytes > MAX_RECORD_BYTES + 1) { + break; + } + chunks.push(Buffer.from(buffer.subarray(0, byteCount))); + const text: string = Buffer.concat(chunks).toString('utf8'); + const newlineIndex: number = text.indexOf('\n'); + if (newlineIndex >= 0) { + const context: IReporterChildContext | undefined = parseAck(text.slice(0, newlineIndex)); + reporter = context ? new HeftChildReporter(descriptorFd, version, context) : undefined; + break; + } + } + } catch (error) { + if (typeof (error as NodeJS.ErrnoException).code !== 'string') { + throw error; + } + } + try { + fs.closeSync(ackDescriptorFd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EBADF') { + throw error; + } + } + return reporter; + } + + public setCommandName(commandName: string): void { + this._commandName = commandName; + } + + public write(data: string, severity: TerminalProviderSeverity): void { + if (severity === TerminalProviderSeverity.verbose && !this.verboseEnabled) { + return; + } + if (severity === TerminalProviderSeverity.debug && !this.debugEnabled) { + return; + } + const stream: 'stdout' | 'stderr' = + severity === TerminalProviderSeverity.warning || severity === TerminalProviderSeverity.error + ? 'stderr' + : 'stdout'; + for (const chunk of chunkUtf8Text(data)) { + this._emit('externalOutput', 'local-sensitive', { stream, text: chunk }); + } + } + + public emitDiagnostic(loggerName: string, error: Error, severity: 'warning' | 'error'): void { + const source: + | { + readonly kind: 'file'; + readonly file: string; + readonly line: number | undefined; + readonly column: number | undefined; + readonly toolName: string; + } + | { readonly kind: 'tool'; readonly toolName: string } = + error instanceof FileError + ? { + kind: 'file', + file: error.absolutePath, + line: error.line, + column: error.column, + toolName: loggerName + } + : { kind: 'tool', toolName: loggerName }; + const diagnostic: Record = { + diagnosticId: crypto.randomUUID(), + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity, + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: loggerName, privacy: 'public' }, + code: { value: error.name, privacy: 'public' }, + message: { value: error.message, privacy: 'local-sensitive' } + }, + source + }; + + try { + this._emit('diagnosticEmitted', 'local-sensitive', diagnostic); + } catch (emitError) { + if (!(emitError instanceof Error) || !emitError.message.includes('protocol limit')) { + throw emitError; + } + this.write(`[${loggerName}] ${severity}: ${error.message}${EOL}`, TerminalProviderSeverity.error); + } + } + + private _emit( + type: 'diagnosticEmitted' | 'externalOutput', + privacy: 'local-sensitive', + payload: unknown + ): void { + const scope: IReporterEventScope | undefined = + this._commandName === undefined ? undefined : { commandName: this._commandName }; + fs.writeSync( + this._descriptorFd, + encodeRecord({ + protocolVersion: PROTOCOL_VERSION, + eventId: `child_${this._nextEventId++}`, + sessionId: this._sessionId, + sequence: this._sequence++, + timestamp: new Date().toISOString(), + source: { + packageName: Constants.heftPackageName, + packageVersion: this._sourceVersion + }, + scope, + privacy, + required: type === 'diagnosticEmitted', + type, + payload + }) + ); + } +} diff --git a/apps/heft/src/pluginFramework/logging/LoggingManager.ts b/apps/heft/src/pluginFramework/logging/LoggingManager.ts index c598d7a5ecb..6ad0609fcd6 100644 --- a/apps/heft/src/pluginFramework/logging/LoggingManager.ts +++ b/apps/heft/src/pluginFramework/logging/LoggingManager.ts @@ -8,9 +8,11 @@ import { } from '@rushstack/node-core-library'; import type { ITerminalProvider } from '@rushstack/terminal'; +import type { HeftChildReporter } from './HeftChildReporter'; import { ScopedLogger } from './ScopedLogger'; export interface ILoggingManagerOptions { terminalProvider: ITerminalProvider; + childReporter?: HeftChildReporter; } export class LoggingManager { @@ -54,7 +56,10 @@ export class LoggingManager { terminalProvider: this.#options.terminalProvider, getShouldPrintStacks: () => this.#shouldPrintStacks, errorHasBeenEmittedCallback: () => (this.#hasAnyErrors = true), - warningHasBeenEmittedCallback: () => (this.#hasAnyWarnings = true) + warningHasBeenEmittedCallback: () => (this.#hasAnyWarnings = true), + structuredDiagnosticCallback: this.#options.childReporter + ? (error, severity) => this.#options.childReporter!.emitDiagnostic(loggerName, error, severity) + : undefined }); this.#scopedLoggers.set(loggerName, scopedLogger); return scopedLogger; diff --git a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts index 50ec990a18a..524940b5a17 100644 --- a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts +++ b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts @@ -54,6 +54,7 @@ export interface IScopedLoggerOptions { getShouldPrintStacks: () => boolean; errorHasBeenEmittedCallback: () => void; warningHasBeenEmittedCallback: () => void; + structuredDiagnosticCallback?: (error: Error, severity: 'warning' | 'error') => void; } export class ScopedLogger implements IScopedLogger { @@ -107,7 +108,11 @@ export class ScopedLogger implements IScopedLogger { public emitError(error: Error): void { this.#options.errorHasBeenEmittedCallback(); this.#errors.push(error); - this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + if (this.#options.structuredDiagnosticCallback) { + this.#options.structuredDiagnosticCallback(error, 'error'); + } else { + this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + } if (this.#shouldPrintStacks && error.stack) { this.terminal.writeErrorLine(error.stack); } @@ -119,7 +124,11 @@ export class ScopedLogger implements IScopedLogger { public emitWarning(warning: Error): void { this.#options.warningHasBeenEmittedCallback(); this.#warnings.push(warning); - this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + if (this.#options.structuredDiagnosticCallback) { + this.#options.structuredDiagnosticCallback(warning, 'warning'); + } else { + this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + } if (this.#shouldPrintStacks && warning.stack) { this.terminal.writeWarningLine(warning.stack); } diff --git a/apps/rush-cli-client/.npmignore b/apps/rush-cli-client/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/apps/rush-cli-client/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md new file mode 100644 index 00000000000..b1c699a7bfb --- /dev/null +++ b/apps/rush-cli-client/README.md @@ -0,0 +1,309 @@ +# @rushstack/rush-cli-client + +Separate `rush-client` and `rushx-client` binaries, opt-in until cutover. Existing +`rush`, `rushx`, and their reporter entrypoints are unchanged. + +Routing precedence: + +1. `--no-daemon` before `--`, help, and never-daemonize commands stay in-process. +2. CI stays in-process unless `RUSH_DAEMON=1` explicitly opts in, even if config enables the daemon. +3. `RUSH_DAEMON` overrides `rush.json`'s `daemon.enabled`; the default is false. +4. Auto-start is considered only after selecting daemon execution. + +`--no-wait` fails immediately when daemon admission is unavailable. +`--wait-timeout SECONDS` (or `--wait-timeout=SECONDS`) overrides the configured queue +timeout; finite nonnegative decimal seconds up to 2147483.647 are accepted and +rounded down to milliseconds. These controls are mutually exclusive and are +consumed before forwarding, never appended to a project script. Arguments after +`--` remain literal script arguments. + +Admission controls also apply to experimental graph requests, but not +`start|stop|restart|status|logs`. They affect daemon admission only; native fallback +retains native command behavior. Waiting positions are shown on interactive stderr, +and admission failures report their typed reason and a nonzero exit code. + +Explicit reporter/output/log-level controls retain the native frontend reporter path. +The current daemon client renders the legacy operation stream; it does not silently +reinterpret requests for JSON, AI, file, or other reporter formats. + +Positively identified built-in `install` and `update` follow the same opt-in routing +precedence as workspace builds and require protocol **0.10** +(`DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR`). They are not submitted to older peers. +Other package mutation, publishing, setup, management, and administrative commands +remain native rather than being forwarded as execution requests. Daemon management +subcommands use their separate control path. Rushx script names are not interpreted +as Rush built-ins. Arguments after `--` are preserved. +Request cwd, environment, argv, width and color are captured before connecting. +The protocol currently expresses request color as a boolean; subscriptions carry +the corresponding color level. There is no SIGWINCH forwarding. + +The standalone host now binds native `build`/`rebuild` requests to a reusable +all-project graph. Native Rush parsing, project selection, graph plugins, and +incremental/cache semantics are reused rather than spawning another Rush CLI. +The client renders operation headers, collated text, and activity events; global +command byte streams remain byte-preserving. A `rushx build` script never claims +to be a workspace build. + +Rushx requests carry `invocationKind: "rushx"` (protocol 0.8), independently of custom +command origin. Native parsing recognizes `-q`, `-d`, and `--ignore-hooks` before the +command; subsequent flags and `--` belong to the script, apart from this client's +explicit admission/escape controls. Older peers fall back before receiving the request +or consuming input. + +The default daemon installs `RushDaemonRequestResolver(existingRushResolver)` to enable real +package-script execution alongside native workspace builds. It reuses native Rushx parsing, escaping, +banner/diagnostics, lifecycle PATH and INIT_CWD preparation, dotenv precedence, and +pnpm injected-dependency synchronization. Only the actual script shell is spawned; +there is no Rush CLI child or synthetic warm graph. Native configuration discovery +is captured by the client and emitted only with daemon output, avoiding duplicate +discovery messages on fallback. + +On Windows, Rushx retains native invocation-path spelling in script cwd, lifecycle variables and +pnpm-sync output, including 8.3 aliases and junctions. Daemon identity and confinement remain physical; +an alias does not create another workspace identity or permit execution outside the workspace. +Alias retargeting while queued fails before execution. Relative `RUSH_TEMP_FOLDER` initialization +uses safe in-process fallback. + +The composite is exported for embedded hosts and wired into the standalone daemon. +No default or cutover flag is flipped. Active Rushx hooks, encrypted dotenv +vaults, changed process-global Rush configuration variables, stale workspace configuration, +and native help require pre-execution fallback. Ignored/recursive hooks retain native +behavior, including skipping post hooks after failure. PTY requirements remain in-process. + +Compatible requests reuse the same native graph. Source changes refresh inputs; +changed configuration or command shape replaces the session and graph in the same +process. Environment, installed dependencies, implementation content, or selected +Rush version changes require a process restart rather than patching the existing +engine. Direct, inherited, and rig-based project configuration uses private native +loaders and is rechecked before execution. External plugins, `.env`, phased +watch/install options, and unsupported event-hook scripts still use typed +pre-execution fallback; this does not exclude the built-in `install` and `update` +commands described above. The native Rush lock is held for preparation and each +coalesced iteration, not while idle; native commands and `--no-daemon` can run +after a completed request without stopping the daemon. + +Native workspace dispatch copies the request envelope and normalizes only the +engine-owned `_RUSH_LIB_PATH` to this daemon's real engine. Foreign client SDK +paths therefore neither select the wrong SDK nor cause a false restart. All other +environment inputs remain unchanged and participate in normal lifecycle checks. + +Protocol 0.10 permits a bounded retry only when a pre-execution command result +explicitly carries `retryAfterRestart: true`. `executeWithDaemonRestartAsync` +waits for old ownership release and a validated successor, then resubmits an eligible +request **at most once**. Command input/output or cancellation prevents retry, +even with the typed flag. Unknown rejections and connection loss never authorize replay. +A started `install` or `update` is never repeated, including after a nonzero exit; +only an unstarted request can receive +the typed retry authorization. Accepted queued requests drain their typed restart +results before the old connection closes. + +Piped input uses protocol 0.7's negotiated stdin admission and EOF. The client does +not read input until the command attaches an input destination, and sends bounded +chunks only as the daemon grants write credits. EOF follows all preceding writes; +binary Ctrl+C bytes in a pipe are data, not cancellation signals. Older peers fall +back before `requestStart` or input consumption, and pre-execution command fallback +preserves the complete pipe for the native entrypoint. +The existing Rush entrypoints resolve project scripts from cwd. Fallback loads the +existing `@microsoft/rush` version-selecting entrypoint in the client process, +preserving its startup checks, output and reporter integration instead of +inventing a cached module path. Before auto-start or explicit start/restart, the client +selects an available daemon whose installed engine is exactly the requested Rush +version, including a native `RUSH_PREVIEW_VERSION` override. It can install a published +daemon release declaring that exact engine dependency into a node-specific Rush cache; +it never overrides dependencies or relabels the bundled engine. Foreign installations +are probed in isolation, and startup rechecks actual runtime version, protocol, and +default request-launch APIs before binding. Incompatible or unavailable launchers use +native fallback for ordinary invocations and fail explicitly for management commands. +Connect-only calls never install packages. Older Rush versions may also reject the new `daemon` config block; use +environment-only opt-in until a supporting Rush release is selected. + +## Configuration + +Every setting uses **environment > config > default**. Boolean overrides accept +only `0`/`1`; numeric overrides accept finite unsigned decimal numbers. Unknown +keys and unknown `RUSH_DAEMON*` variables fail validation. + +| `daemon` key | Environment override | Default | Runtime status | +| --- | --- | --- | --- | +| `enabled` | `RUSH_DAEMON` | false | Client routing | +| `autoStart` | `RUSH_DAEMON_AUTO_START` | true | Only after opt-in | +| `idleTimeoutSeconds` | `RUSH_DAEMON_IDLE_TIMEOUT_SECONDS` | 900 | Host idle shutdown after request/output/cleanup drain | +| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Sent through existing admission contract | +| `watch` | `RUSH_DAEMON_WATCH` | false | Persistent host observation of requested warm projects; false keeps root/config guards only. Never schedules builds | +| `warmIdleTimeoutSeconds` | `RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS` | 300 | Idle runner, project-watcher and retained-result eviction | +| `warmMemoryBudgetMB` | `RUSH_DAEMON_WARM_MEMORY_BUDGET_MB` | 512 | Best-effort sampled RSS budget in MiB, not a hard ceiling | +| `warmSetMaxProjects` | `RUSH_DAEMON_WARM_SET_MAX_PROJECTS` | 20 | Best-effort retained-project limit; never trims requested execution | +| `autoWarmByTelemetry` | `RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY` | false | Measured retention ranking with conservative LRU fallback; no speculative scripts | + +Timeouts must be positive and at most 2147483.647 seconds; queue timeout additionally +accepts zero and is rounded down to milliseconds. Memory budget must be positive +and no larger than JavaScript's maximum safe integer. Project count must be a +positive safe integer. The session automatically owns these warm policies for its real +graph and watchers. Executing/prepared work and protected resources are not evicted. +Missing child-memory measurements stay explicitly unknown; unavoidable active/base +memory pressure is reported rather than hidden. No warm-set setting changes build correctness. +Project observation previously ran regardless of `watch`. Its existing default `false` +now disables host project observation; set it to `true` to retain observation between +requests. Every explicit native request still refreshes inputs and effective configuration. +Changing this flag neither discards warm results/runners nor starts scripts; safe idle +maintenance applies watcher changes and reports deferred or failed cleanup. + +## Management + +`rush-client daemon start` explicitly requests startup, independently of +`daemon.enabled`, `autoStart`, or CI execution routing. It conflicts with +`--no-daemon`. It is idempotent: an existing compatible daemon is reused, not +reconfigured. Startup uses the same detached, locked launcher as automatic +startup and selects an attested launcher rather than guessing a path for another Rush version. +With an explicit matching launcher, a daemon implementation-version mismatch triggers +ownership-checked replacement under the start mutex before executing any command. +It does not replace a peer lacking safe shutdown support. Foreign package installation +is a client preparation step; host self-restart selects only bundled or already cached +compatible installations, never installing while the old workspace is being cleaned up. + +`rush-client daemon status` only connects and checks hello/pong. It never starts +a process, reclaims files, or treats a PID file as evidence of readiness. Both +commands print one JSON object with `state: "ready"`, `socketPath`, and the actual +pong fields (`uptimeMs`, available versions, optional `pid` and +`residentMemoryBytes`, and an optional `workspace` snapshot). Exit code 0 means protocol +readiness, not build support. An unreachable/incompatible endpoint, invalid +arguments, or startup failure returns exit code 1 with a diagnostic. + +The optional workspace snapshot reports the provider generation/token, graph existence, +and available warm accounting without initializing a graph. Missing fields are unknown, +not proof of zero memory or successful reload. Status can inspect a protocol-compatible +daemon with a different implementation version; start requires the bundled version to match. + +| `workspace` field | Meaning | +| --- | --- | +| `generation`, `generationToken` | Current provider generation and installed session identity | +| `lastReloadTier` | Lifecycle-owned `0` initial/reuse, `1` successful in-process reload, or `2` requested restart; older peers may omit it | +| `graphInitialized` | A graph exists; this does not attest build success | +| `warmSet.configuration` | Effective `watch` and four warm-resource settings; older peers may omit `watch` | +| `warmSet.maintenanceState`, `warmSet.maintenanceFailure` | Running, quiescing, stopped or failed maintenance; stopping it does not itself free resources | +| `warmSet.retainedProjectNames`, `warmSet.protectedProjectNames`, `warmSet.watchedProjectNames` | Actual retained/protected projects and resident project observation | +| `warmSet.daemonResidentMemoryBytes`, `warmSet.measuredRunnerMemoryBytes`, `warmSet.unmeasuredRunnerCount` | Daemon RSS, last-completion child RSS samples, and explicitly unmeasured resident runners; descendants are not included | +| `warmSet.overMemoryBudget`, `warmSet.overProjectLimit`, `warmSet.cleanupFailures`, `warmSet.deferredReason` | Outstanding footprint pressure, cleanup failures and maintenance deferral | + +An absent `warmSet` means no controller is attached, not that the workspace consumes +no memory. Status reads `lastReloadTier` from the lifecycle (zero for a host without one); +it does not infer a tier from PID/generation changes or initiate a reload. Tier `2` +attests a restart request, not completion of successor startup or success of a command. + +`rush-client daemon stop` requires protocol >= 0.6 and waits for `shutdownAck` +followed by EOF. It reports `state: "shutdownAccepted"` with exit code 0; this +does not assert successful workspace disposal. An absent/unreachable daemon, +unsupported protocol, missing acknowledgement, or timeout returns exit code 1. +It does not auto-start anything. + +`rush-client daemon restart` first verifies that the selected Rush version has a +launcher and captures the original lock's PID/start timestamp, checking that it +matches pong's positive PID and the selected endpoint, then performs acknowledged +shutdown. It waits for original ownership release or a demonstrably dead owner +before calling the existing locked starter. A live/reused owner fails closed at +the startup deadline; no PID is killed and no live ownership record is deleted. +A newly +started/reused successor must pass hello/ping before reporting `state: "ready"`. +An absent daemon must be started explicitly with `daemon start`. + +Restart is explicit even when automatic startup or CI execution routing is +disabled, but conflicts with `--no-daemon`. The two-phase host retains ownership +until workspace disposal succeeds, so embedded hosts can restart a workspace +without exiting their process. Failed cleanup retains the live lock and causes a +bounded restart failure, even if the socket has already disappeared. A changed +owner is reconnected and validated, not overwritten. + +`rush-client daemon logs` prints a snapshot of the selected workspace's launcher +log, whether the daemon is running or stopped. It never connects or auto-starts. +The stable path comes from `getDaemonLogFilePath(paths)`: `.log`. +Detached child stdout and stderr are appended to this file across restarts; the +parent closes its descriptor after spawning. On POSIX the launcher enforces mode +`0600` and rejects linked destinations; Windows uses the existing per-user +transport directory permissions. + +Reading is bounded to the size observed when the log is opened, with chunked, +backpressured output. Empty logs succeed without output; missing/unreadable logs +or invalid destinations fail with a diagnostic and exit code 1. A launcher log +may not exist for a daemon started outside this client. No `--follow` or rotation +policy is added. + +This is the **text launcher stdout/stderr log**, including startup errors—not +WS5 structured observability or a subscription to request-scoped events. + +## Experimental graph reference client + +Set `RUSH_DAEMON_EXPERIMENTAL=1` and use an explicitly started daemon: + +```sh +export RUSH_DAEMON_EXPERIMENTAL=1 +rush-client daemon start +rush-client daemon graph show +rush-client build --to my-project +rush-client daemon graph scope-out --project my-project +rush-client daemon graph scope-in --operation 'my-project (compile)' +rush-client daemon graph invalidate --project my-project +rush-client daemon graph pause +rush-client daemon graph status +rush-client daemon graph resume +rush-client daemon graph watch +``` + +All graph commands connect only; they never auto-start, initialize the graph, or +fall back to native Rush. Cold `show` and `status` report `initialized: false` +without an `operations` field. Other verbs fail explicitly until a supported, +explicit build request has initialized the graph. The gate is checked both by the +CLI and against the server request's environment. Older or unsupported servers, +invalid arguments, and unknown selectors fail, never invoke a shell. + +`show` and `status` both emit a complete point-in-time metadata snapshot, including +operation IDs, exact project/phase names, native enabled states, observed statuses, +dependency IDs, manual-mode/scheduled flags, and a path-free invalidation summary. +An operation without an observed execution status reports `null`. An idle operation +whose actual completed result was evicted reports `READY` for request-time revalidation, +not historical success; inspection does not schedule work. Snapshots contain no +environment, runner, log, or terminal objects. + +Protocol 0.9 snapshots include an opaque `workspaceGeneration` token. Every mutation +echoes a token, checked under exclusive admission before touching the graph. The +token changes on soft reload and process replacement, preventing stale operation +references from affecting a new generation. Use `--generation TOKEN` with a token +from an earlier snapshot to preserve that reference; the client never refreshes an +explicit token. Without this option, the client privately reads current status +before submitting the mutation. A reload between those requests fails closed. +Mutations reject older peers before submission; read-only inspection remains compatible. + +`scope-in`, `scope-out`, and `invalidate` require one or more repeated +`--project NAME` or `--operation ID` pairs. Names and IDs match exactly; there are +no globs or implicit all-project selections. Every selector is validated before +any mutation. Scope-in enables transitive dependencies. Scope-out includes +transitive consumers and uses native safe-disable, which also prunes dependencies +no longer needed by enabled operations. Invalidation marks selected native results +stale without scheduling work. Mutations use exclusive workspace admission and +never change an active iteration. Scope changes/invalidation reject an already +prepared iteration instead of modifying stale execution records. + +Pause/resume change native `pauseNextIteration`: manual mode gates automatically +scheduled iterations, **not explicit build requests**. Resume does not create new +work. If an engine owner has already prepared an automatic iteration, resume +acquires the native execution lease, discards the old unstarted plan, reconciles +inputs, and prepares its replacement before releasing it. Admission and the native +lease remain held until native idle, including +if the resume client disconnects. The reference client's watch command is not a +build scheduler; the default lazy engine still rebuilds only on explicit requests. +Native build requests apply their own selections, so a graph scope is not a +persistent override of later build arguments. + +Graph stdout is NDJSON only. Snapshots use the existing `extension` event envelope +with `payload.name: "rushd.graph-snapshot"` and +`payload.data: { requestId, snapshot }`. The existing `requestResult` is emitted as +the terminal record; queue positions and request rejections also retain their +control-message shapes. Local failures use `{ kind: "graphError", message }`. +No human renderer, ANSI styling, or graph-specific transport is involved. + +`watch` emits an initial snapshot followed by relevant graph state, invalidation, +and idle updates. It holds no scheduler lease, so other clients can build. +Slow consumers receive coalesced latest snapshots rather than every intermediate +transition or an unbounded event history. SIGINT/SIGTERM cancel the subscription +and wait for the authoritative aborted result (exit 130). Disconnect removes +subscriber resources without cancelling another client's build. Graph hooks are +installed once per graph, not once per connection. diff --git a/apps/rush-cli-client/bin/rush-client b/apps/rush-cli-client/bin/rush-client new file mode 100755 index 00000000000..eef2fc27066 --- /dev/null +++ b/apps/rush-cli-client/bin/rush-client @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib-commonjs/start.js'); diff --git a/apps/rush-cli-client/bin/rushx-client b/apps/rush-cli-client/bin/rushx-client new file mode 100755 index 00000000000..8820423f29a --- /dev/null +++ b/apps/rush-cli-client/bin/rushx-client @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib-commonjs/startRushX.js'); diff --git a/apps/rush-cli-client/config/jest.config.json b/apps/rush-cli-client/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/apps/rush-cli-client/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/apps/rush-cli-client/config/rig.json b/apps/rush-cli-client/config/rig.json new file mode 100644 index 00000000000..596af2f5a4e --- /dev/null +++ b/apps/rush-cli-client/config/rig.json @@ -0,0 +1,3 @@ +{ + "rigPackageName": "local-node-rig" +} diff --git a/apps/rush-cli-client/eslint.config.js b/apps/rush-cli-client/eslint.config.js new file mode 100644 index 00000000000..07a548c5123 --- /dev/null +++ b/apps/rush-cli-client/eslint.config.js @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { parserOptions: { tsconfigRootDir: __dirname } } + } +]; diff --git a/apps/rush-cli-client/package.json b/apps/rush-cli-client/package.json new file mode 100644 index 00000000000..5f7f719803e --- /dev/null +++ b/apps/rush-cli-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@rushstack/rush-cli-client", + "version": "0.1.0", + "description": "Opt-in Rush and rushx daemon clients", + "bin": { + "rush-client": "./bin/rush-client", + "rushx-client": "./bin/rushx-client" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "apps/rush-cli-client" + }, + "engines": { + "node": ">=20.9.0" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@microsoft/rush": "workspace:*", + "@microsoft/rush-lib": "workspace:*", + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-client-core": "workspace:*", + "@rushstack/rush-daemon": "workspace:*", + "@rushstack/rush-daemon-transport": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/rush-terminal-renderer": "workspace:*", + "@rushstack/terminal": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + } +} diff --git a/apps/rush-cli-client/src/ClientAdmissionControls.ts b/apps/rush-cli-client/src/ClientAdmissionControls.ts new file mode 100644 index 00000000000..fdef84bb448 --- /dev/null +++ b/apps/rush-cli-client/src/ClientAdmissionControls.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS, + validateDaemonRequestAdmissionOptions, + type IDaemonRequestAdmissionOptions +} from '@rushstack/rush-daemon-protocol'; + +export interface IClientAdmissionControls { + readonly argv: ReadonlyArray; + readonly admission: IDaemonRequestAdmissionOptions | undefined; +} + +/** Consumes only daemon admission controls before the explicit script-argument separator. */ +export function parseClientAdmissionControls(argv: ReadonlyArray): IClientAdmissionControls { + const remaining: string[] = []; + let noWait: boolean = false; + let waitTimeoutMs: number | undefined; + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg === '--') { + remaining.push(...argv.slice(index)); + break; + } + if (arg === '--no-wait') { + noWait = true; + } else if (arg.startsWith('--no-wait=')) { + throw new Error('--no-wait does not accept a value.'); + } else if (arg === '--wait-timeout' || arg.startsWith('--wait-timeout=')) { + if (waitTimeoutMs !== undefined) throw new Error('--wait-timeout may be specified only once.'); + const value: string | undefined = arg === '--wait-timeout' + ? argv[++index] + : arg.slice('--wait-timeout='.length); + if (value === undefined || !/^\d+(?:\.\d+)?$/.test(value)) { + throw new Error('--wait-timeout requires a nonnegative decimal number of seconds.'); + } + const seconds: number = Number(value); + if (seconds > MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS / 1000) { + throw new RangeError('--wait-timeout exceeds the supported timer range.'); + } + waitTimeoutMs = Math.floor(seconds * 1000); + validateDaemonRequestAdmissionOptions({ waitTimeoutMs }); + } else { + remaining.push(arg); + } + } + if (noWait && waitTimeoutMs !== undefined) { + throw new Error('--no-wait and --wait-timeout cannot be combined.'); + } + const admission: IDaemonRequestAdmissionOptions | undefined = noWait + ? { noWait: true } + : waitTimeoutMs === undefined ? undefined : { waitTimeoutMs }; + return { argv: remaining, admission }; +} diff --git a/apps/rush-cli-client/src/ClientOperationRenderer.ts b/apps/rush-cli-client/src/ClientOperationRenderer.ts new file mode 100644 index 00000000000..5a682185c81 --- /dev/null +++ b/apps/rush-cli-client/src/ClientOperationRenderer.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonVerbosity, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; +import { + DaemonRendererHost, + type DaemonRenderStream, + type IDaemonRendererTerminal +} from '@rushstack/rush-terminal-renderer'; + +interface IRenderedChunk { + readonly text: string; + readonly stream: DaemonRenderStream; +} + +interface IClientOperationRendererOptions { + readonly requestId: string; + readonly terminal: Pick; + readonly colorLevel: number; + readonly verbosity: DaemonVerbosity; + readonly writeAsync: (bytes: Uint8Array, stream: DaemonRenderStream) => Promise; +} + +/** Adapts the synchronous terminal renderer to the wire client's asynchronous output contract. */ +export class ClientOperationRenderer { + readonly #options: IClientOperationRendererOptions; + readonly #host: DaemonRendererHost; + #chunks: IRenderedChunk[] = []; + + public constructor(options: IClientOperationRendererOptions) { + this.#options = options; + this.#host = new DaemonRendererHost({ + colorLevel: options.colorLevel, + verbosity: options.verbosity, + terminal: { + get columns() { return options.terminal.columns; }, + get isTTY() { return options.terminal.isTTY; }, + write: (text, stream) => { this.#chunks.push({ text, stream }); } + } + }); + } + + public initializeAsync(): Promise { + return this.#host.initializeAsync(); + } + + public async writeEventAsync(event: IDaemonEventEnvelope): Promise { + try { + this.#host.handleEvent(event); + } finally { + await this.#flushAsync(); + } + } + + public async writeLogAsync( + bytes: Uint8Array, + operationId: string, + stream: DaemonRenderStream + ): Promise { + if (operationId === this.#options.requestId) { + // Global-command streams are raw bytes, not collated operation text. + await this.#options.writeAsync(bytes, stream); + return; + } + try { + this.#host.handleLogChunk(operationId, stream, bytes); + } finally { + await this.#flushAsync(); + } + } + + public async closeAsync(): Promise { + try { + await this.#host.closeAsync(); + } finally { + await this.#flushAsync(); + } + } + + async #flushAsync(): Promise { + const chunks: IRenderedChunk[] = this.#chunks; + this.#chunks = []; + for (const chunk of chunks) { + await this.#options.writeAsync(Buffer.from(chunk.text), chunk.stream); + } + } +} diff --git a/apps/rush-cli-client/src/GraphGenerationControls.ts b/apps/rush-cli-client/src/GraphGenerationControls.ts new file mode 100644 index 00000000000..d40bc0ec8d6 --- /dev/null +++ b/apps/rush-cli-client/src/GraphGenerationControls.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + captureDaemonRequest, DaemonClient, type DaemonClientOutcome, type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import { + DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR, + isDaemonControlRecord, + RUSHD_GRAPH_SNAPSHOT, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +export interface IGraphGenerationControls { + readonly argv: ReadonlyArray; + readonly mutation: boolean; + readonly generation?: string; +} + +export function parseGraphGenerationControls(argv: ReadonlyArray): IGraphGenerationControls { + const mutation: boolean = ['scope-in', 'scope-out', 'invalidate', 'pause', 'resume'].includes(argv[1]); + const remaining: string[] = []; + let generation: string | undefined; + for (let i: number = 0; i < argv.length; i++) { + const arg: string = argv[i]; + if (arg === '--generation' || arg.startsWith('--generation=')) { + if (!mutation || generation !== undefined) { + throw new Error('--generation is accepted once, on a graph mutation only.'); + } + generation = arg === '--generation' ? argv[++i] : arg.slice('--generation='.length); + if (!generation || generation.trim() !== generation || generation.startsWith('--')) { + throw new Error('--generation requires the opaque token from a graph snapshot.'); + } + } else { + remaining.push(arg); + } + } + return { argv: remaining, mutation, generation }; +} + +/** Reads a current token without printing a second snapshot or mutating anything. */ +export async function readGraphGenerationAsync( + connection: IConnectOrStartDaemonOptions, + environment: Readonly +): Promise { + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: connection.paths.socketPath }); + try { + if (client.protocolVersion.minor < DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR) { + throw new Error('Graph mutations require generation-aware protocol 0.9 or newer.'); + } + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: ['daemon', 'graph', 'status'], commandName: 'daemon', commandOrigin: 'built-in', + cwd: process.cwd(), environment, terminal: { isTTY: false, supportsColor: false } + }); + let generation: string | undefined; + const outcome: DaemonClientOutcome = await client.executeAsync({ + request, + onEventAsync: async (event) => { + const payload: unknown = event.payload; + if ( + event.type !== 'extension' || !isDaemonControlRecord(payload) || + payload.name !== RUSHD_GRAPH_SNAPSHOT || !isDaemonControlRecord(payload.data) || + payload.data.requestId !== request.requestId || !isDaemonControlRecord(payload.data.snapshot) || + typeof payload.data.snapshot.initialized !== 'boolean' + ) throw new Error('Invalid graph-generation snapshot.'); + const token: unknown = payload.data.snapshot.workspaceGeneration; + if (typeof token !== 'string' || token.length === 0) throw new Error('Missing graph generation token.'); + generation = token; + } + }); + if (outcome.kind !== 'result' || outcome.result.exitCode !== 0 || generation === undefined) { + throw new Error('Could not obtain a current graph generation; no mutation was attempted.'); + } + return generation; + } finally { + await client.closeAsync(); + } +} diff --git a/apps/rush-cli-client/src/daemonCommands.ts b/apps/rush-cli-client/src/daemonCommands.ts new file mode 100644 index 00000000000..faff413e85f --- /dev/null +++ b/apps/rush-cli-client/src/daemonCommands.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + DaemonClient, + connectOrStartDaemonAsync, + requestDaemonShutdownAsync, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import type { IDaemonLockfile } from '@rushstack/rush-daemon-transport'; +import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; + +import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; +import { printDaemonLogAsync } from './daemonLogs'; +import { executeDaemonGraphCommandAsync } from './daemonGraph'; +import { writeStreamAsync } from './writeStreamAsync'; + +export interface IDaemonCommandOptions { + readonly argv: ReadonlyArray; + readonly environment: Readonly; + readonly rushJsonPath?: string; + readonly rushVersion: string; + readonly admission?: IDaemonRequestAdmissionOptions; +} + +export async function executeDaemonCommandAsync(options: IDaemonCommandOptions): Promise { + const command: string | undefined = options.argv[0]; + if (options.admission && command !== 'graph') { + throw new Error('Daemon admission controls apply to command execution or graph requests, not lifecycle commands.'); + } + if (command === 'graph') { + await executeDaemonGraphCommandAsync(options); + return; + } + if ( + options.argv.length !== 1 || + (command !== 'start' && + command !== 'status' && + command !== 'stop' && + command !== 'restart' && + command !== 'logs') + ) { + throw new Error('Usage: rush-client daemon start|status|stop|restart|logs'); + } + if (!options.rushJsonPath) throw new Error('Daemon management requires a repository containing rush.json.'); + const mayStart: boolean = command === 'start' || command === 'restart'; + const connectionOptions: IConnectOrStartDaemonOptions = await getDaemonConnectionOptionsAsync( + path.dirname(options.rushJsonPath), + options.rushVersion, + options.environment, + mayStart + ); + if (command === 'logs') { + await printDaemonLogAsync(connectionOptions.paths); + return; + } + // Status observes the selected endpoint, including a compatible daemon from a different client version. + // It never starts a process or trusts a PID file as evidence of readiness. + const client: DaemonClient = + command === 'start' + ? await connectOrStartDaemonAsync(connectionOptions) + : await DaemonClient.connectAsync({ socketPath: connectionOptions.paths.socketPath }); + try { + if (command === 'stop') { + await client.shutdownAsync(); + await writeStatusAsync({ + state: 'shutdownAccepted', + socketPath: connectionOptions.paths.socketPath + }); + return; + } + const readyClient: DaemonClient = + command === 'restart' ? await restartDaemonAsync(client, connectionOptions) : client; + try { + await writeStatusAsync({ + state: 'ready', + socketPath: connectionOptions.paths.socketPath, + ...(await readyClient.status) + }); + } finally { + if (readyClient !== client) await readyClient.closeAsync(); + } + } finally { + await client.closeAsync(); + } +} + +async function restartDaemonAsync( + client: DaemonClient, + options: IConnectOrStartDaemonOptions +): Promise { + const previousDaemon: Pick = + await requestDaemonShutdownAsync(client, options.paths); + return await connectOrStartDaemonAsync({ ...options, previousDaemon }); +} + +function writeStatusAsync(status: object): Promise { + return writeStreamAsync(process.stdout, Buffer.from(`${JSON.stringify(status)}\n`)); +} diff --git a/apps/rush-cli-client/src/daemonConnectionOptions.ts b/apps/rush-cli-client/src/daemonConnectionOptions.ts new file mode 100644 index 00000000000..ebefef0de15 --- /dev/null +++ b/apps/rush-cli-client/src/daemonConnectionOptions.ts @@ -0,0 +1,68 @@ +// 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 { Rush } from '@microsoft/rush-lib'; +import { JsonFile } from '@rushstack/node-core-library'; +import type { IConnectOrStartDaemonOptions } from '@rushstack/rush-client-core'; +import { computeDaemonWorkspaceKey, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport'; +import { readDaemonInstallationMetadata } from '@rushstack/rush-daemon/lib/DaemonInstallation'; +import { + DaemonLauncherUnavailableError, + getSelectedDaemonStartCommand, + selectDaemonLauncherAsync, + type IVersionSelectedDaemonLaunch +} from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +export function getDaemonConnectionOptions( + repoRoot: string, + rushVersion: string, + environment: Readonly, + autoStart: boolean +): IConnectOrStartDaemonOptions { + const canonicalRepoRoot: string = fs.realpathSync.native(repoRoot); + const daemonPackagePath: string = require.resolve('@rushstack/rush-daemon/package.json'); + const daemonPackage: { version: string; bin: { rushd: string } } = JsonFile.load(daemonPackagePath); + if (autoStart && readDaemonInstallationMetadata(daemonPackagePath).rushVersion !== rushVersion) { + throw new DaemonLauncherUnavailableError( + rushVersion, + 'The synchronous launcher only supports its installed engine; use asynchronous version selection.' + ); + } + return { + paths: resolveDaemonPathsFromProcess(computeDaemonWorkspaceKey({ canonicalRepoRoot, rushVersion })), + expectedDaemonVersion: daemonPackage.version, + startCommand: autoStart + ? getSelectedDaemonStartCommand(daemonPackagePath, { + repoRoot: canonicalRepoRoot, + rushVersion, + environment + }) + : undefined + }; +} + +/** Resolves an attested launcher before startup; connect-only invocations never install packages. */ +export async function getDaemonConnectionOptionsAsync( + repoRoot: string, + rushVersion: string, + environment: Readonly, + autoStart: boolean +): Promise { + const options: IConnectOrStartDaemonOptions = getDaemonConnectionOptions( + repoRoot, + rushVersion, + environment, + false + ); + if (!autoStart) return { paths: options.paths }; + // The bundled runtime is already loaded here; its bootstrap re-attests before binding. + if (rushVersion === Rush.version) return getDaemonConnectionOptions(repoRoot, rushVersion, environment, true); + const launch: IVersionSelectedDaemonLaunch = await selectDaemonLauncherAsync({ + repoRoot: fs.realpathSync.native(repoRoot), + rushVersion, + environment + }); + return { ...options, expectedDaemonVersion: launch.daemonVersion, startCommand: launch.startCommand }; +} diff --git a/apps/rush-cli-client/src/daemonGraph.ts b/apps/rush-cli-client/src/daemonGraph.ts new file mode 100644 index 00000000000..717a7f8e43c --- /dev/null +++ b/apps/rush-cli-client/src/daemonGraph.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + captureDaemonRequest, DaemonClient, type DaemonClientOutcome, type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import { DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR, isDaemonControlRecord, RUSHD_GRAPH_SNAPSHOT, type IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonCommandOptions } from './daemonCommands'; +import { getDaemonConnectionOptions } from './daemonConnectionOptions'; +import { writeStreamAsync } from './writeStreamAsync'; +import { + parseGraphGenerationControls, readGraphGenerationAsync, type IGraphGenerationControls +} from './GraphGenerationControls'; + +/** The reference graph client has no renderer, native graph imports, or in-process fallback. */ +export async function executeDaemonGraphCommandAsync(options: IDaemonCommandOptions): Promise { + try { + await runGraphAsync(options); + } catch (error) { + process.exitCode = 1; + await writeJsonAsync({ kind: 'graphError', message: error instanceof Error ? error.message : String(error) }); + } +} + +async function runGraphAsync(options: IDaemonCommandOptions): Promise { + if (options.environment.RUSH_DAEMON_EXPERIMENTAL !== '1') { + throw new Error('Graph commands require RUSH_DAEMON_EXPERIMENTAL=1.'); + } + if (!options.rushJsonPath) throw new Error('Graph commands require a repository containing rush.json.'); + const connection: IConnectOrStartDaemonOptions = getDaemonConnectionOptions( + path.dirname(options.rushJsonPath), options.rushVersion, options.environment, false + ); + const controls: IGraphGenerationControls = parseGraphGenerationControls(options.argv); + const expectedWorkspaceGeneration: string | undefined = controls.mutation + ? controls.generation ?? await readGraphGenerationAsync(connection, options.environment) + : undefined; + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: ['daemon', ...controls.argv], + commandName: 'daemon', + commandOrigin: 'built-in', + cwd: process.cwd(), + environment: options.environment, + admission: options.admission, + expectedWorkspaceGeneration, + terminal: { isTTY: false, supportsColor: false, acceptsStdin: false } + }); + // Like daemon status, graph inspection connects only. Starting a daemon is an explicit management command. + const client: DaemonClient = await DaemonClient.connectAsync({ + socketPath: connection.paths.socketPath, + capabilities: { isTTY: false, colorLevel: 0 } + }); + if (controls.mutation && client.protocolVersion.minor < DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR) { + await client.closeAsync(); + throw new Error('Graph mutations require generation-aware protocol 0.9 or newer.'); + } + const abort: AbortController = new AbortController(); + const onSignal = (): void => abort.abort(); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + let sawSnapshot: boolean = false; + let outcome: DaemonClientOutcome; + try { + outcome = await client.executeAsync({ + request, + abortSignal: abort.signal, + onEventAsync: async (event) => { + const payload: unknown = event.payload; + if ( + event.type !== 'extension' || !isDaemonControlRecord(payload) || payload.name !== RUSHD_GRAPH_SNAPSHOT || + !isDaemonControlRecord(payload.data) || payload.data.requestId !== request.requestId || + !isDaemonControlRecord(payload.data.snapshot) || typeof payload.data.snapshot.initialized !== 'boolean' + ) { + throw new Error('The daemon sent an invalid graph snapshot event.'); + } + if ( + expectedWorkspaceGeneration !== undefined && + payload.data.snapshot.workspaceGeneration !== expectedWorkspaceGeneration + ) throw new Error('The graph mutation response belongs to a different workspace generation.'); + sawSnapshot = true; + await writeJsonAsync(event); + }, + onQueuePositionAsync: (position) => + writeJsonAsync({ kind: 'queuePosition', payload: { requestId: request.requestId, position } }) + }); + } finally { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + await client.closeAsync(); + } + if (outcome.kind === 'fallback') { + throw new Error(`The daemon does not support graph requests: ${outcome.message ?? outcome.reason}. No native fallback was attempted.`); + } + if (outcome.kind === 'rejected') { + process.exitCode = 1; + await writeJsonAsync({ kind: 'requestRejected', payload: outcome.rejection }); + return; + } + if (outcome.result.outcome === 'success' && !sawSnapshot) { + throw new Error('The daemon completed without a graph snapshot; graph support is required. No native fallback was attempted.'); + } + process.exitCode = outcome.result.exitCode; + await writeJsonAsync({ kind: 'requestResult', payload: outcome.result }); +} + +function writeJsonAsync(value: object): Promise { + return writeStreamAsync(process.stdout, Buffer.from(`${JSON.stringify(value)}\n`)); +} diff --git a/apps/rush-cli-client/src/daemonLogs.ts b/apps/rush-cli-client/src/daemonLogs.ts new file mode 100644 index 00000000000..b0a09232fa9 --- /dev/null +++ b/apps/rush-cli-client/src/daemonLogs.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { constants, type Stats } from 'node:fs'; +import { open, type FileHandle } from 'node:fs/promises'; + +import { getDaemonLogFilePath } from '@rushstack/rush-client-core'; +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { writeStreamAsync } from './writeStreamAsync'; + +const READ_BUFFER_BYTES: number = 64 * 1024; + +/** Prints a fixed-size snapshot without connecting to or starting the daemon. */ +export async function printDaemonLogAsync(paths: IDaemonPaths): Promise { + const logFilePath: string = getDaemonLogFilePath(paths); + let file: FileHandle; + try { + // These distinct native flags have non-overlapping values. + file = await open( + logFilePath, + constants.O_RDONLY + (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW + constants.O_NONBLOCK) + ); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + throw new Error( + `No launcher log exists at ${logFilePath}. Logs are created when this client starts a daemon.`, + { cause: error } + ); + } + throw error; + } + try { + const stats: Stats = await file.stat(); + if (!stats.isFile() || stats.nlink !== 1) { + throw new Error(`Launcher log must be a regular, unshared file: ${logFilePath}`); + } + if (!Number.isSafeInteger(stats.size) || stats.size < 0) { + throw new Error(`Launcher log size cannot be represented safely: ${logFilePath}`); + } + const buffer: Buffer = Buffer.alloc(READ_BUFFER_BYTES); + let position: number = 0; + while (position < stats.size) { + const { bytesRead } = await file.read( + buffer, + 0, + Math.min(buffer.length, stats.size - position), + position + ); + if (bytesRead === 0) + throw new Error(`Launcher log was truncated while reading ${logFilePath}; retry the command.`); + await writeStreamAsync(process.stdout, buffer.subarray(0, bytesRead)); + position += bytesRead; + } + } finally { + await file.close(); + } +} diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts new file mode 100644 index 00000000000..25cc87d569c --- /dev/null +++ b/apps/rush-cli-client/src/launchClient.ts @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + Rush, + RushConfiguration, + daemonEnvironmentVariables, + resolveDaemonConfiguration, + type IDaemonConfigurationJson +} from '@microsoft/rush-lib'; +import { JsonFile } from '@rushstack/node-core-library'; +import { + DaemonClientError, + captureDaemonRequest, + connectOrStartDaemonAsync, + executeWithDaemonRestartAsync, + type DaemonClient, + type DaemonClientOutcome, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import type { DaemonVerbosity, IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; +import { ConsoleTerminalProvider } from '@rushstack/terminal'; +import { MinimalRushConfiguration } from '@microsoft/rush/lib/MinimalRushConfiguration'; +import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +import { executeDaemonCommandAsync } from './daemonCommands'; +import { ClientOperationRenderer } from './ClientOperationRenderer'; +import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; +import { selectClientRoute, type IClientRoute } from './routing'; +import { writeStreamAsync } from './writeStreamAsync'; + +interface IWorkspaceJson { + readonly rushVersion: string; + readonly daemon?: IDaemonConfigurationJson; +} + +export async function launchClientAsync(rushx: boolean): Promise { + const cwd: string = process.cwd(); + const environment: Readonly = Object.freeze({ ...process.env }); + const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ startingFolder: cwd }); + const workspace: IWorkspaceJson | undefined = rushJsonPath ? JsonFile.load(rushJsonPath) : undefined; + const config: Readonly> = resolveDaemonConfiguration( + workspace?.daemon, + environment + ); + const route: IClientRoute = selectClientRoute({ + argv: process.argv.slice(2), + environment, + enabled: config.enabled, + rushx + }); + const selectedVersion: string = environment.RUSH_PREVIEW_VERSION ?? workspace?.rushVersion ?? Rush.version; + if (!rushx && route.commandName === 'daemon') { + if ((route.argv[1] === 'start' || route.argv[1] === 'restart') && process.argv.includes('--no-daemon')) { + throw new Error(`--no-daemon cannot be combined with daemon ${route.argv[1]}.`); + } + await executeDaemonCommandAsync({ + argv: route.argv.slice(1), + environment, + rushJsonPath, + rushVersion: selectedVersion, + admission: route.admission + }); + return; + } + if (!route.daemon || !rushJsonPath || route.commandName === undefined) { + launchInProcess(route.argv, rushx, selectedVersion); + return; + } + const terminal: ConsoleTerminalProvider = new ConsoleTerminalProvider(); + const verbosity: DaemonVerbosity = route.argv.includes('--verbose') || route.argv.includes('-v') + ? 'verbose' + : 'quiet'; + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: route.argv, + commandName: route.commandName, + // The native resolver additionally validates the parsed action; rushx scripts never claim this origin. + commandOrigin: !rushx && ['build', 'rebuild', 'install', 'update'].includes(route.commandName) ? 'built-in' : 'custom', + invocationKind: rushx ? 'rushx' : 'rush', + cwd, + environment, + terminal: { + isTTY: !!process.stdout.isTTY, + supportsColor: terminal.supportsColor, + columns: process.stdout.columns, + acceptsStdin: true + }, + admission: route.admission ?? { waitTimeoutMs: Math.floor(config.queueTimeoutSeconds * 1000) } + }); + let connection: IConnectOrStartDaemonOptions; + let client: DaemonClient; + try { + connection = { + ...await getDaemonConnectionOptionsAsync( + path.dirname(rushJsonPath), selectedVersion, request.environment, config.autoStart + ), + capabilities: { + isTTY: request.terminal.isTTY, + columns: request.terminal.columns, + colorLevel: terminal.supportsColor ? 1 : 0, + verbosity + } + }; + client = await connectOrStartDaemonAsync(connection); + } catch (error) { + if (!(error instanceof DaemonClientError) && !(error instanceof DaemonLauncherUnavailableError)) throw error; + process.stderr.write(`rush-client: ${error.message} Using in-process Rush.\n`); + launchInProcess(route.argv, rushx, selectedVersion); + return; + } + const abort: AbortController = new AbortController(); + const onSignal = (): void => abort.abort(); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + const renderer: ClientOperationRenderer = new ClientOperationRenderer({ + requestId: request.requestId, + colorLevel: terminal.supportsColor ? 1 : 0, + verbosity, + terminal: { + get columns() { return process.stdout.columns ?? 80; }, + get isTTY() { return !!process.stdout.isTTY; } + }, + writeAsync: (bytes, stream) => writeStreamAsync( + stream === 'stderr' ? process.stderr : process.stdout, bytes + ) + }); + let outcome: DaemonClientOutcome; + const discoveryLines: string[] = []; + const writeDiscoveryAsync = async (): Promise => { + if (discoveryLines.length > 0) { + await writeStreamAsync(process.stdout, Buffer.from(discoveryLines.splice(0).join('\n') + '\n')); + } + }; + try { + if (rushx) MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line)); + await renderer.initializeAsync(); + outcome = await executeWithDaemonRestartAsync(client, connection, { + request, + abortSignal: abort.signal, + onStdoutAsync: async (bytes, operationId) => { + await writeDiscoveryAsync(); + await renderer.writeLogAsync(bytes, operationId, 'stdout'); + }, + onStderrAsync: async (bytes, operationId) => { + await writeDiscoveryAsync(); + await renderer.writeLogAsync(bytes, operationId, 'stderr'); + }, + onEventAsync: (event) => renderer.writeEventAsync(event), + onQueuePositionAsync: process.stderr.isTTY + ? (position) => writeStreamAsync( + process.stderr, Buffer.from(`rush-client: waiting for daemon admission (position ${position}).\n`) + ) + : undefined, + stdin: process.stdin, + requiresStdinEnd: !process.stdin.isTTY, + cancelOnCtrlC: !!process.stdin.isTTY, + initialRawMode: !!process.stdin.isRaw, + setRawMode: process.stdin.isTTY ? (enabled) => { process.stdin.setRawMode(enabled); } : undefined + }); + } finally { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + try { + await renderer.closeAsync(); + } finally { + await client.closeAsync(); + } + } + if (outcome.kind === 'result') { + process.exitCode = outcome.result.exitCode; + if (outcome.result.admissionErrorCode) { + await writeStreamAsync( + process.stderr, + Buffer.from(`rush-client: daemon admission failed (${outcome.result.admissionErrorCode}).\n`) + ); + } + } else if (outcome.kind === 'rejected') { + throw new Error(`Daemon rejected the request (${outcome.rejection.code}): ${outcome.rejection.message}`); + } else if (abort.signal.aborted) { + process.exitCode = 130; + } else { + process.stderr.write(`rush-client: ${outcome.message ?? outcome.reason}; using in-process Rush.\n`); + launchInProcess(route.argv, rushx, selectedVersion); + } +} + +function launchInProcess(argv: ReadonlyArray, rushx: boolean, selectedVersion: string): void { + const executable: string = rushx ? 'rushx' : 'rush'; + const rushFolder: string = path.dirname(require.resolve('@microsoft/rush/package.json')); + process.argv = [process.execPath, path.join(rushFolder, 'bin', executable), ...argv]; + if (selectedVersion !== Rush.version) { + // Old Rush releases reject new RUSH_* names. Only strip this launcher's own inputs; + // the request snapshot was captured earlier and is never mutated. + for (const name of [...Object.values(daemonEnvironmentVariables), 'RUSH_DAEMON_EXPERIMENTAL']) { + delete process.env[name]; + } + } + require('@microsoft/rush/lib/start'); +} diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts new file mode 100644 index 00000000000..867fd37e23f --- /dev/null +++ b/apps/rush-cli-client/src/routing.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; +import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib'; + +import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; + +const neverDaemonize: ReadonlySet = new Set([ + 'add', + 'change', + 'check', + 'deploy', + 'init', + 'init-autoinstaller', + 'init-deploy', + 'link', + 'publish', + 'purge', + 'remove', + 'scan', + 'setup', + 'unlink', + 'update-autoinstaller', + 'version', + 'help', + 'daemon' +]); + +export interface IClientRouteOptions { + readonly argv: ReadonlyArray; + readonly environment: Readonly>; + readonly enabled: boolean; + readonly rushx: boolean; +} + +export interface IClientRoute { + readonly argv: ReadonlyArray; + readonly daemon: boolean; + readonly commandName: string | undefined; + readonly admission: IDaemonRequestAdmissionOptions | undefined; +} + +/** Routing never parses action parameters or relabels a custom command as a built-in. */ +export function selectClientRoute(options: IClientRouteOptions): IClientRoute { + const controls: IClientAdmissionControls = parseClientAdmissionControls(options.argv); + const separator: number = controls.argv.indexOf('--'); + const prefix: ReadonlyArray = separator < 0 ? controls.argv : controls.argv.slice(0, separator); + const noDaemon: boolean = prefix.includes('--no-daemon'); + const argv: ReadonlyArray = [ + ...prefix.filter((arg) => arg !== '--no-daemon'), + ...(separator < 0 ? [] : controls.argv.slice(separator)) + ]; + const rushxArguments: IRushXCommandLineArguments | undefined = + options.rushx ? RushXCommand.parseArguments(argv, options.environment) : undefined; + const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; + const reporterControls: boolean = + options.environment.RUSH_LOG_LEVEL !== undefined || + (options.environment.RUSH_REPORTER !== undefined && options.environment.RUSH_REPORTER !== 'legacy') || + (!options.rushx && prefix.some((arg) => + ['--reporter', '--output', '--log-level'].some((name) => arg === name || arg.startsWith(`${name}=`)) + )); + const ci: boolean = ['CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION'].some((key) => { + const value: string | undefined = options.environment[key]; + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; + }); + const daemon: boolean = + options.enabled && + !reporterControls && + !noDaemon && + !!commandName && + !commandName.startsWith('-') && + (options.rushx || !neverDaemonize.has(commandName)) && + !(rushxArguments ? rushxArguments.help : prefix.includes('--help') || prefix.includes('-h')) && + (!ci || options.environment.RUSH_DAEMON === '1'); + return { argv, commandName, daemon, admission: controls.admission }; +} diff --git a/apps/rush-cli-client/src/start.ts b/apps/rush-cli-client/src/start.ts new file mode 100644 index 00000000000..d940f3f82a5 --- /dev/null +++ b/apps/rush-cli-client/src/start.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { launchClientAsync } from './launchClient'; + +launchClientAsync(false).catch((error: Error) => { + process.stderr.write(`rush-client: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/apps/rush-cli-client/src/startRushX.ts b/apps/rush-cli-client/src/startRushX.ts new file mode 100644 index 00000000000..9c0d3be3ff5 --- /dev/null +++ b/apps/rush-cli-client/src/startRushX.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { launchClientAsync } from './launchClient'; + +launchClientAsync(true).catch((error: Error) => { + process.stderr.write(`rushx-client: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/apps/rush-cli-client/src/test/CliSignalTestProcess.ts b/apps/rush-cli-client/src/test/CliSignalTestProcess.ts new file mode 100644 index 00000000000..230759efca4 --- /dev/null +++ b/apps/rush-cli-client/src/test/CliSignalTestProcess.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +// Windows kill('SIGINT') terminates a process without running its signal handlers. +process.once('message', (message: unknown) => { + if (message !== 'SIGINT' || !process.emit('SIGINT')) { + throw new Error('Expected SIGINT with an installed CLI signal handler.'); + } + process.disconnect!(); +}); +process.argv[1] = path.resolve(__dirname, '../../bin/rush-client'); +require(process.argv[1]); diff --git a/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts new file mode 100644 index 00000000000..0919a754a9f --- /dev/null +++ b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseClientAdmissionControls } from '../ClientAdmissionControls'; +import { selectClientRoute } from '../routing'; + +describe(parseClientAdmissionControls.name, () => { + it.each([ + { argv: ['build'], remaining: ['build'], admission: undefined }, + { argv: ['build', '--no-wait'], remaining: ['build'], admission: { noWait: true } }, + { argv: ['--wait-timeout', '1.25', 'build'], remaining: ['build'], admission: { waitTimeoutMs: 1250 } }, + { argv: ['build', '--wait-timeout=0'], remaining: ['build'], admission: { waitTimeoutMs: 0 } }, + { + argv: ['build', '--wait-timeout=2147483.647'], remaining: ['build'], + admission: { waitTimeoutMs: 2147483647 } + }, + { + argv: ['script', '--', '--no-wait', '--wait-timeout', '2'], + remaining: ['script', '--', '--no-wait', '--wait-timeout', '2'], admission: undefined + } + ])('parses $argv', ({ argv, remaining, admission }) => { + expect(parseClientAdmissionControls(argv)).toEqual({ argv: remaining, admission }); + }); + + it.each([ + ['build', '--wait-timeout'], + ['build', '--wait-timeout='], + ['build', '--wait-timeout=-1'], + ['build', '--wait-timeout=Infinity'], + ['build', '--wait-timeout=1e3'], + ['build', '--wait-timeout=2147483.648'], + ['build', '--wait-timeout=2147483.6471'], + ['build', '--wait-timeout=1', '--wait-timeout=2'], + ['build', '--no-wait', '--wait-timeout=0'], + ['build', '--no-wait=false'] + ])('rejects invalid admission arguments %j', (...argv) => { + expect(() => parseClientAdmissionControls(argv)).toThrow(); + }); + + it('removes daemon-only controls before native fallback while retaining script arguments', () => { + expect(selectClientRoute({ + argv: ['build', '--no-daemon', '--no-wait', '--', '--wait-timeout=2'], + enabled: true, environment: {}, rushx: false + })).toEqual({ + argv: ['build', '--', '--wait-timeout=2'], + commandName: 'build', daemon: false, admission: { noWait: true } + }); + }); +}); diff --git a/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts b/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts new file mode 100644 index 00000000000..f9d7a77bc42 --- /dev/null +++ b/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { EOL } from 'node:os'; + +import { + DAEMON_PROTOCOL_VERSION, + RUSHD_OPERATION_STREAM_CLOSED, + type DaemonEventType, + type IDaemonEventEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { ClientOperationRenderer } from '../ClientOperationRenderer'; + +function event(type: DaemonEventType, payload: unknown): IDaemonEventEnvelope { + return { + eventId: 'event', sessionId: 'session', sequence: 1, timestamp: new Date().toISOString(), + protocolVersion: DAEMON_PROTOCOL_VERSION, + source: { packageName: 'test', packageVersion: '1.0.0' }, + privacy: 'public', required: true, type, payload + }; +} + +describe(ClientOperationRenderer.name, () => { + it('renders operation chrome and global stderr activity without adding a second newline', async () => { + const output: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }; + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async (bytes, stream) => { output[stream] += Buffer.from(bytes).toString(); } + }); + await renderer.initializeAsync(); + await renderer.writeEventAsync(event('operationRegistered', { operationId: 'project', silent: false })); + await renderer.writeLogAsync(Buffer.from('built\n'), 'project', 'stdout'); + await renderer.writeEventAsync(event('extension', { + name: RUSHD_OPERATION_STREAM_CLOSED, data: { operationId: 'project' } + })); + await renderer.writeEventAsync(event('activityChanged', { text: 'warning\n', stream: 'stderr' })); + await renderer.closeAsync(); + expect(output.stdout).toContain('==[ project ]'); + expect(output.stdout).toContain(`built${EOL}`); + expect(output.stderr).toBe(`warning${EOL}`); + }); + + it('preserves global command bytes without collation or text decoding', async () => { + const output: Buffer[] = []; + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async (bytes) => { output.push(Buffer.from(bytes)); } + }); + const bytes: Buffer = Buffer.from([0, 255, 3, 13]); + await renderer.initializeAsync(); + await renderer.writeLogAsync(bytes, 'request', 'stdout'); + await renderer.closeAsync(); + expect(Buffer.concat(output)).toEqual(bytes); + }); + + it('propagates a backpressured output failure instead of reporting success', async () => { + const failure: Error = new Error('output failed'); + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async () => { throw failure; } + }); + await renderer.initializeAsync(); + await expect(renderer.writeEventAsync(event('activityChanged', { + text: 'activity', stream: 'stdout' + }))).rejects.toBe(failure); + await renderer.closeAsync(); + }); +}); diff --git a/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts b/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts new file mode 100644 index 00000000000..edad2c1a7dc --- /dev/null +++ b/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseGraphGenerationControls } from '../GraphGenerationControls'; + +describe(parseGraphGenerationControls.name, () => { + it.each(['scope-in', 'scope-out', 'invalidate', 'pause', 'resume'])( + 'preserves explicit generation and selectors for %s', + (verb) => { + expect(parseGraphGenerationControls(['graph', verb, '--generation', 'old-token', '--project', 'a'])) + .toEqual({ argv: ['graph', verb, '--project', 'a'], mutation: true, generation: 'old-token' }); + expect(parseGraphGenerationControls(['graph', verb, '--generation=old-token'])) + .toEqual({ argv: ['graph', verb], mutation: true, generation: 'old-token' }); + expect(parseGraphGenerationControls(['graph', verb])) + .toEqual({ argv: ['graph', verb], mutation: true, generation: undefined }); + } + ); + + it.each([ + ['graph', 'show', '--generation', 'token'], + ['graph', 'status', '--generation=token'], + ['graph', 'watch', '--generation=token'], + ['graph', 'pause', '--generation'], + ['graph', 'pause', '--generation='], + ['graph', 'pause', '--generation', ' token'], + ['graph', 'pause', '--generation', '--project'], + ['graph', 'pause', '--generation=one', '--generation=two'] + ])('rejects malformed generation controls %j', (...argv) => { + expect(() => parseGraphGenerationControls(argv)).toThrow('--generation'); + }); +}); diff --git a/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts b/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts new file mode 100644 index 00000000000..36c058e33a3 --- /dev/null +++ b/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { + createDaemonHelloAck, DAEMON_PROTOCOL_VERSION, DaemonFrameType, decodeDaemonControlMessage, + encodeDaemonControlMessage, encodeDaemonEventFrame, RUSHD_GRAPH_SNAPSHOT, + type DaemonControlMessage, type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +describe('generation-aware graph reference client', () => { + it.each([ + 'current', 'explicit', 'stale-explicit', 'stale-preflight', 'mismatched-response', + 'missing-token', 'old-auto', 'old-explicit', 'downgraded-peer' + ])('fences %s without native fallback or mutation replay', async (mode) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-generation-')); + const peers: Set = new Set(); + const requests: IDaemonRequestEnvelope[] = []; + let listener: DaemonFrameListener | undefined; + let connections: number = 0; + try { + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: Rush.version, npmVersion: '10.0.0', projects: [] + })); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, process.env, false); + listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection) => { + peers.add(connection); + connection.onClosed(() => { peers.delete(connection); }); + const old: boolean = mode.startsWith('old-') || (mode === 'downgraded-peer' && ++connections === 2); + const protocolVersion = old ? { major: 0, minor: 8 } : DAEMON_PROTOCOL_VERSION; + const send = (message: DaemonControlMessage): Promise => connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(message) + }); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await send(createDaemonHelloAck(protocolVersion, 'generation-peer')); + } else if (message.kind === 'ping') { + await send({ kind: 'pong', payload: { uptimeMs: 1 } }); + } else if (message.kind === 'requestStart') { + const request: IDaemonRequestEnvelope = message.payload; + requests.push(request); + const mutation: boolean = request.argv[2] === 'pause'; + if (mutation && mode.startsWith('stale-')) { + await send({ + kind: 'requestRejected', + payload: { requestId: request.requestId, code: 'invalidRequest', message: 'Stale graph generation.' } + }); + return; + } + await connection.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion, eventId: request.requestId, sessionId: 'generation-peer', sequence: 1, + timestamp: new Date().toISOString(), type: 'extension', privacy: 'local-sensitive', required: true, + source: { packageName: '@rushstack/rush-daemon', packageVersion: '0.0.0' }, + payload: { name: RUSHD_GRAPH_SNAPSHOT, data: { + requestId: request.requestId, + snapshot: { + initialized: true, + workspaceGeneration: mode === 'missing-token' ? undefined + : mutation && mode === 'mismatched-response' ? 'replacement-token' : 'current-token' + } + } } + }) + }); + await send({ + kind: 'requestResult', + payload: { requestId: request.requestId, exitCode: 0, outcome: 'success', aborted: false } + }); + } + }); + } + }); + const explicit: boolean = ['explicit', 'stale-explicit', 'old-explicit'].includes(mode); + const token: string = mode === 'stale-explicit' ? 'old-token' : 'current-token'; + const child = spawn(process.execPath, [ + path.resolve(__dirname, '../../bin/rush-client'), 'daemon', 'graph', 'pause', + ...(explicit ? ['--generation', token] : []) + ], { + cwd: folder, env: { ...process.env, RUSH_DAEMON_EXPERIMENTAL: '1' }, stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { stdout += bytes.toString(); }); + child.stderr.on('data', (bytes: Buffer) => { stderr += bytes.toString(); }); + expect((await once(child, 'close'))[0]).toBe(['current', 'explicit'].includes(mode) ? 0 : 1); + expect(stderr).toBe(''); + expect(stdout).not.toMatch(/using in-process|Usage: rush /); + const records = stdout.trim().split('\n').map((line) => JSON.parse(line)); + const mutations = requests.filter((request) => request.argv[2] === 'pause'); + if (mode.startsWith('old-') || mode === 'missing-token' || mode === 'downgraded-peer') { + expect(mutations).toHaveLength(0); + expect(records).toEqual([{ kind: 'graphError', message: expect.any(String) }]); + } else { + expect(mutations).toHaveLength(1); + expect(mutations[0]).toMatchObject({ + argv: ['daemon', 'graph', 'pause'], expectedWorkspaceGeneration: token + }); + expect(requests).toHaveLength(explicit ? 1 : 2); + if (mode.startsWith('stale-')) { + expect(records).toEqual([{ kind: 'requestRejected', payload: expect.objectContaining({ code: 'invalidRequest' }) }]); + } else if (mode === 'mismatched-response') { + expect(records).toEqual([{ kind: 'graphError', message: expect.stringContaining('different workspace generation') }]); + } else { + expect(records).toHaveLength(2); + expect(records[0].payload.data.requestId).toBe(mutations[0].requestId); + expect(records[1]).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + } + } + } finally { + await Promise.all(Array.from(peers, (peer) => peer.closeAsync())); + await listener?.closeAsync(); + fs.rmSync(folder, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts b/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts new file mode 100644 index 00000000000..79670f27900 --- /dev/null +++ b/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createNativeBuildTestFixture } from './NativeBuildTestFixture'; + +describe('native build fixture lifetime', () => { + it('joins the whole old callback without rebinding it to a later fixture', async () => { + const old = createNativeBuildTestFixture(); + const next = createNativeBuildTestFixture(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let continued: boolean = false; + let disposed: boolean = false; + const work = old.runAsync(async ({ folder, invokeAsync }) => { + await released; + expect(folder).toBe(old.folder); + expect(() => invokeAsync(['build'])).toThrow('already closing'); + fs.writeFileSync(path.join(folder, 'late-callback.txt'), 'old fixture only'); + continued = true; + }); + const closing = old.closeAsync().then(() => { + disposed = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(disposed).toBe(false); + expect(fs.existsSync(old.folder)).toBe(true); + release(); + await work; + await closing; + expect(continued).toBe(true); + expect(fs.existsSync(old.folder)).toBe(false); + expect(fs.existsSync(path.join(next.folder, 'late-callback.txt'))).toBe(false); + expect(fs.readFileSync(path.join(next.folder, 'a/input.txt'), 'utf8')).toBe('one'); + } finally { + release(); + try { + await closing; + } finally { + await next.closeAsync(); + } + } + }, 15000); + + it.each([false, true])( + 'terminates an owned watch before removal (registered during cleanup: %s)', + async (late) => { + const fixture = createNativeBuildTestFixture(); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + cwd: fixture.folder, + stdio: 'ignore' + }); + const closed = once(child, 'close'); + if (!late) fixture.trackWatch(child, closed); + let continued: boolean = false; + const work = fixture.runAsync(async () => { + if (late) expect(() => fixture.trackWatch(child, closed)).toThrow('already closing'); + await closed; + continued = true; + }); + await fixture.closeAsync(); + await work; + expect(continued).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect(fs.existsSync(fixture.folder)).toBe(false); + }, + 15000 + ); + + it('leaves callback failures observable while cleanup joins the rejected work', async () => { + const fixture = createNativeBuildTestFixture(); + const failure = new Error('native fixture callback failure'); + const work = fixture.runAsync(async () => { + throw failure; + }); + try { + await expect(work).rejects.toBe(failure); + } finally { + await fixture.closeAsync(); + } + expect(fs.existsSync(fixture.folder)).toBe(false); + }, 15000); +}); diff --git a/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts new file mode 100644 index 00000000000..6521c6f13e5 --- /dev/null +++ b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { removeTestFolderAsync } from '@rushstack/rush-daemon/lib/test/TestProcessExit'; +import { RUSHD_GRAPH_SNAPSHOT, type IDaemonGraphSnapshotPayload } from '@rushstack/rush-daemon-protocol'; +import { + computeDaemonWorkspaceKey, + resolveDaemonPaths, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +export interface INativeBuildResult { + readonly code: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +export interface INativeBuildTestFixture { + readonly folder: string; + readonly environment: NodeJS.ProcessEnv; + readonly paths: IDaemonPaths; + invokeAsync(argv: ReadonlyArray, rushx?: boolean): Promise; + snapshotAsync(...args: string[]): Promise; + runAsync(work: (fixture: INativeBuildTestFixture) => Promise): Promise; + trackWatch(child: ChildProcess, closed: Promise): void; + closeAsync(): Promise; +} + +export function createNativeBuildTestFixture(): INativeBuildTestFixture { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-native-')); + const environment: NodeJS.ProcessEnv = { + ...process.env, + RUSH_DAEMON: '1', + RUSH_REPORTER: 'legacy', + CI: 'false', + TF_BUILD: 'false', + GITHUB_ACTIONS: 'false', + XDG_RUNTIME_DIR: folder + }; + const invocationClosures: Promise[] = []; + const callbacks: Promise[] = []; + const watchers: Set = new Set(); + let acceptingInvocations: boolean = true; + const write = (name: string, text: string): void => { + const filename: string = path.join(folder, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, text); + }; + write( + 'rush.json', + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + daemon: { enabled: true, autoStart: true, idleTimeoutSeconds: 30 }, + projectFolderMinDepth: 1, + projects: ['a', 'b'].map((name) => ({ packageName: name, projectFolder: name })) + }) + ); + write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\nruns.txt\nrushd*/\n'); + write('common/temp/last-link.flag', '{}'); + write('common/config/rush/npm-shrinkwrap.json', '{"lockfileVersion":3,"packages":{}}'); + write( + 'common/config/rush/command-line.json', + JSON.stringify({ + phases: [{ name: '_phase:compile', dependencies: { upstream: ['_phase:compile'] } }], + commands: [ + { + commandKind: 'phased', + name: 'build', + phases: ['_phase:compile'], + incremental: true, + enableParallelism: true + } + ] + }) + ); + for (const name of ['a', 'b']) { + write( + `${name}/package.json`, + JSON.stringify({ + name, + version: '1.0.0', + scripts: { '_phase:compile': 'node build.cjs', build: 'node -e "console.log(\'rushx-only\')"' }, + dependencies: name === 'b' ? { a: '1.0.0' } : {} + }) + ); + write(`${name}/input.txt`, 'one'); + write( + `${name}/build.cjs`, + "const fs=require('node:fs');const name=require('./package.json').name;" + + "const value=fs.readFileSync('input.txt','utf8');" + + "fs.appendFileSync('../runs.txt',name+':'+value+'\\n');console.log('built-'+name+'-'+value);" + ); + } + execFileSync('git', ['init', '--quiet'], { cwd: folder }); + execFileSync('git', ['config', '--local', 'core.autocrlf', 'false'], { cwd: folder }); + execFileSync('git', ['add', '.'], { cwd: folder }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Client Test', + '-c', + 'user.email=client@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ], + { cwd: folder } + ); + const paths: IDaemonPaths = resolveDaemonPaths( + { platform: process.platform, env: environment, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + computeDaemonWorkspaceKey({ + canonicalRepoRoot: fs.realpathSync.native(folder), + rushVersion: Rush.version + }) + ); + + function invokeAsync(argv: ReadonlyArray, rushx: boolean = false): Promise { + if (!acceptingInvocations) throw new Error('The native build fixture is already closing.'); + return spawnClientAsync(argv, rushx); + } + + async function spawnClientAsync( + argv: ReadonlyArray, + rushx: boolean = false + ): Promise { + const entry: string = path.resolve(__dirname, rushx ? '../../bin/rushx-client' : '../../bin/rush-client'); + const child = spawn(process.execPath, [entry, ...argv], { + cwd: rushx ? path.join(folder, 'b') : folder, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { + stdout += bytes.toString(); + }); + child.stderr.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + const closed: Promise = once(child, 'close'); + invocationClosures.push(closed); + const [code] = await closed; + return { code: typeof code === 'number' ? code : undefined, stdout, stderr }; + } + + async function snapshotAsync(...args: string[]): Promise { + const result = await invokeAsync(['daemon', 'graph', ...args]); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).not.toContain('\u001b'); + const records = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ type: 'extension', payload: { name: RUSHD_GRAPH_SNAPSHOT } }); + expect(records[1]).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + return records[0].payload.data.snapshot; + } + + const fixture: INativeBuildTestFixture = { + folder, + environment, + paths, + invokeAsync, + snapshotAsync, + runAsync: (work) => { + if (!acceptingInvocations) throw new Error('The native build fixture is already closing.'); + const running: Promise = Promise.resolve().then(() => work(fixture)); + callbacks.push(running); + return running; + }, + trackWatch: (child, closed) => { + watchers.add(child); + child.once('close', () => watchers.delete(child)); + invocationClosures.push(closed); + if (!acceptingInvocations) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + throw new Error('The native build fixture is already closing.'); + } + }, + closeAsync: async () => { + acceptingInvocations = false; + for (const child of watchers) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + } + // Jest owns callback failures. Drain complete continuations, not only their current child process. + await Promise.allSettled(callbacks); + await Promise.allSettled(invocationClosures); + if (fs.existsSync(paths.lockfilePath)) { + const stopped: INativeBuildResult = await spawnClientAsync(['daemon', 'stop']); + expect(stopped.code).toBe(0); + } + const deadline: number = Date.now() + 5000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(20); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + await removeTestFolderAsync(folder); + } + }; + return fixture; +} diff --git a/apps/rush-cli-client/src/test/RushXDaemon.test.ts b/apps/rush-cli-client/src/test/RushXDaemon.test.ts new file mode 100644 index 00000000000..7df778e2115 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemon.test.ts @@ -0,0 +1,150 @@ +// 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 { PassThrough } from 'node:stream'; + +import { RushXDaemonTestFixture, type IRequestResult, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx through a real daemon', () => { + let fixture: RushXDaemonTestFixture; + let cwd: string; + let serverCwd: string; + let serverEnvironment: NodeJS.ProcessEnv; + + beforeEach(async () => { + fixture = new RushXDaemonTestFixture(); + await fixture.startAsync(); + cwd = path.join(fixture.folder, 'projects/a'); + serverCwd = process.cwd(); + serverEnvironment = { ...process.env }; + }); + + afterEach(async () => { + expect(process.cwd()).toBe(serverCwd); + expect(process.env).toEqual(serverEnvironment); + expect(fixture.session.operationGraph).toBeUndefined(); + await fixture[Symbol.asyncDispose](); + }); + + it('executes rushx build through the actual CLI and never enters the phased resolver', async () => { + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + const native: IScriptResult = await fixture.invokeAsync(true, ['build'], cwd); + const daemon: IScriptResult = await fixture.invokeAsync(false, ['build'], cwd); + expect(daemon).toEqual(native); + expect(daemon.exitCode).toBe(0); + expect(phased).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(cwd, 'runs.txt'), 'utf8')).toBe('ran\nran\n'); + }); + + it('isolates overlapping requests from different nested cwds, homes and environments', async () => { + fixture.write('second-home/.rush-user/.env', 'USER_VALUE=second-user\n'); + const requests = ['a', 'b'].map((name) => { + const project: string = path.join(fixture.folder, 'projects', name); + const home: string = path.join(fixture.folder, name === 'a' ? 'home' : 'second-home'); + const environment = fixture.environment({ CLIENT_MARKER: name, HOME: home, USERPROFILE: home }); + return { project, nested: path.join(project, 'subfolder'), environment }; + }); + const native = await Promise.all(requests.map(({ nested, environment }) => + fixture.invokeAsync(true, ['--quiet', 'build'], nested, environment) + )); + const daemon = await Promise.all(requests.map(({ nested, environment }) => + fixture.runAsync(fixture.request(['--quiet', 'build'], nested, environment)) + )); + daemon.forEach((result, index) => { + expect({ stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }).toEqual(native[index]); + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + cwd: requests[index].project, invoked: requests[index].nested, + init: path.join(fixture.folder, 'common/temp'), + bin: path.join(requests[index].project, 'node_modules/.bin'), + marker: index === 0 ? 'a' : 'b', repo: 'from-repo', + user: index === 0 ? 'from-user' : 'second-user', + order: 'from-repo', client: 'from-client', recursive: '1' + }); + expect(JSON.parse(result.stdout.toString())).not.toHaveProperty('npmConfig'); + expect(JSON.parse(result.stdout.toString())).not.toHaveProperty('absent'); + }); + }); + + it('uses native parsing and escaping for arbitrary arguments, flags after the command and --', async () => { + const args: string[] = [ + '-q', '--debug', 'args', '--help', '--quiet', '--reporter=json', + 'two words', 'single\' quote', '"double quote"', '; echo bad', 'a & b', 'a\\ b', + '--', '--no-daemon', '--no-wait', '--wait-timeout', 'bogus' + ]; + const native: IScriptResult = await fixture.invokeAsync(true, args, cwd); + expect(await fixture.invokeAsync(false, args, cwd)).toEqual(native); + expect(JSON.parse(native.stdout.toString())).toEqual(args.slice(3)); + }); + + it.each(['', 'single\'quote', '"double"', '$(printf native)', 'a\\b'])( + 'preserves native escaping semantics even for shell-sensitive argument %s', async (arg) => { + const argv: string[] = ['-q', 'args', arg]; + expect(await fixture.invokeAsync(false, argv, cwd)).toEqual(await fixture.invokeAsync(true, argv, cwd)); + } + ); + + it.each([0, 257, 3 * 1024 * 1024])('preserves raw binary pipe bytes and EOF (%s bytes)', async (size) => { + const input: Buffer = Buffer.alloc(size); + for (let index: number = 0; index < size; index++) input[index] = index % 256; + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'pipe'], cwd, undefined, input); + expect(await fixture.invokeAsync(false, ['-q', 'pipe'], cwd, undefined, input)).toEqual(native); + expect(native.stdout).toEqual(input); + expect(native.stderr).toEqual(Buffer.from([255, 0, 3, 10])); + expect(native.exitCode).toBe(0); + }); + + it('preserves the exact nonzero exit code, command display and native error suffix', async () => { + const args: string[] = ['fail', 'two words', '--flag']; + const native: IScriptResult = await fixture.invokeAsync(true, args, cwd); + expect(await fixture.invokeAsync(false, args, cwd)).toEqual(native); + expect(native.exitCode).toBe(7); + expect(native.stderr.toString()).toContain('raw-err\x1b[31mError: Failed calling'); + }); + + it('retains unregistered-project warnings and native missing-script errors without fallback', async () => { + for (const [project, script] of [['unregistered', 'args'], ['a', 'missing']]) { + const folder: string = path.join(fixture.folder, 'projects', project); + const native: IScriptResult = await fixture.invokeAsync(true, [script], folder); + expect(await fixture.invokeAsync(false, [script], folder)).toEqual(native); + } + }); + + it('does not replace a script failure with pipe errors when the script exits without reading', async () => { + const input: Buffer = Buffer.alloc(3 * 1024 * 1024); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'early'], cwd, undefined, input); + expect(await fixture.invokeAsync(false, ['-q', 'early'], cwd, undefined, input)).toEqual(native); + expect(native.exitCode).toBe(23); + }); + + it('rejects symlink cwd escapes and forged Rushx origins before consuming input', async () => { + const escaped: string = path.join(fixture.folder, 'outside'); + fs.symlinkSync(path.dirname(fixture.folder), escaped, process.platform === 'win32' ? 'junction' : 'dir'); + for (const request of [ + fixture.request(['build'], escaped), + { ...fixture.request(['build'], cwd), commandOrigin: 'built-in' as const }, + { ...fixture.request(['daemon'], cwd), commandOrigin: 'built-in' as const } + ]) { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result: IRequestResult = await fixture.runAsync(request, undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'rejected', rejection: { code: 'invalidRequest' } }); + expect(input.read().toString()).toBe('untouched'); + } + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + }); + + it('leaves a controlling-terminal request in-process without emulating a PTY or reading stdin', async () => { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const request = fixture.request(['pipe'], cwd); + const result: IRequestResult = await fixture.runAsync({ + ...request, terminal: { ...request.terminal, terminalRequirement: 'controllingTerminal' } + }, undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'controllingTerminalRequired' }); + expect(input.read().toString()).toBe('untouched'); + }); +}); diff --git a/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts b/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts new file mode 100644 index 00000000000..9fbe153ac59 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts @@ -0,0 +1,179 @@ +// 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 os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { RushXDaemonTestFixture, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx invocation namespaces', () => { + let fixture: RushXDaemonTestFixture; + let aliasContainer: string; + let aliasRoot: string; + let physicalRoot: string; + let serverCwd: string; + let serverEnvironment: NodeJS.ProcessEnv; + + beforeEach(async () => { + fixture = new RushXDaemonTestFixture(false, true); + physicalRoot = fs.realpathSync.native(fixture.folder); + aliasContainer = fs.mkdtempSync(path.join(os.tmpdir(), 'rushx-invocation-alias-')); + aliasRoot = path.join(aliasContainer, 'workspace'); + fs.symlinkSync(physicalRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + fixture.write('projects/a/script.cjs', ` +const path = require('node:path'); +console.log(JSON.stringify({ + cwd: process.cwd(), invoked: process.env.RUSH_INVOKED_FOLDER, + init: process.env.INIT_CWD, bin: process.env.PATH.split(path.delimiter)[0], + base: process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER, marker: process.env.CLIENT_MARKER +})); +`); + await fixture.startAsync(); + serverCwd = process.cwd(); + serverEnvironment = { ...process.env }; + }); + + afterEach(async () => { + try { + expect(process.cwd()).toBe(serverCwd); + expect(process.env).toEqual(serverEnvironment); + expect(fixture.session.metadata.repoRoot).toBe(physicalRoot); + expect(fixture.session.rushConfiguration.rushJsonFolder).toBe(physicalRoot); + expect(fixture.session.operationGraph).toBeUndefined(); + } finally { + await fixture[Symbol.asyncDispose](); + fs.rmSync(aliasContainer, { recursive: true, force: true }); + } + }); + + it('preserves native cwd, invocation, PATH and configuration namespaces across aliases on one host', async () => { + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + for (const root of [aliasRoot, physicalRoot]) { + const cwd: string = path.join(root, 'projects/a/subfolder'); + const environment = fixture.environment({ + RUSHSTACK_FILE_ERROR_BASE_FOLDER: undefined, + CLIENT_MARKER: root + }); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'build'], cwd, environment); + expect(await fixture.invokeAsync(false, ['-q', 'build'], cwd, environment)).toEqual(native); + expect(native.exitCode).toBe(0); + const namespaceRoot: string = process.platform === 'win32' ? root : physicalRoot; + expect(JSON.parse(native.stdout.toString())).toEqual({ + cwd: path.join(namespaceRoot, 'projects/a'), + invoked: path.join(namespaceRoot, 'projects/a/subfolder'), + init: path.join(namespaceRoot, 'common/temp'), + bin: path.join(namespaceRoot, 'projects/a/node_modules/.bin'), + base: namespaceRoot, + marker: root + }); + } + expect(phased).not.toHaveBeenCalled(); + }); + + it('preserves native registration warnings for a project alias instead of substituting physical membership', async () => { + const cwd: string = path.join(fixture.folder, 'project-alias'); + fs.symlinkSync(path.join(physicalRoot, 'projects/a'), cwd, process.platform === 'win32' ? 'junction' : 'dir'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'args'], cwd); + expect(await fixture.invokeAsync(false, ['-q', 'args'], cwd)).toEqual(native); + expect(native.exitCode).toBe(0); + expect(native.stdout.toString().includes('this project is not registered')).toBe(process.platform === 'win32'); + }); + + it('preserves exact alias-qualified native pnpm-sync diagnostics without rewriting displayed paths', async () => { + fixture.write('projects/a/node_modules/.pnpm-sync.json', '{"version":"incompatible-fixture-version"}'); + const cwd: string = path.join(aliasRoot, 'projects/a/subfolder'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-d', 'sync'], cwd); + expect(await fixture.invokeAsync(false, ['-d', 'sync'], cwd)).toEqual(native); + expect(native.exitCode).toBe(1); + const namespaceRoot: string = process.platform === 'win32' ? aliasRoot : physicalRoot; + expect(native.stdout.toString()).toContain( + `Starting operation for ${path.join(namespaceRoot, 'projects/a/node_modules/.pnpm-sync.json')}` + ); + }); + + it('rejects a workspace alias retargeted outside the physical workspace before reading stdin', async () => { + const outsideRoot: string = path.join(aliasContainer, 'outside'); + fs.mkdirSync(path.join(outsideRoot, 'projects/a'), { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'projects/a/package.json'), '{"name":"outside","version":"1.0.0"}'); + fs.unlinkSync(aliasRoot); + fs.symlinkSync(outsideRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result = await fixture.runAsync( + fixture.request(['build'], path.join(aliasRoot, 'projects/a')), undefined, { stdin: input } + ); + expect(result.outcome).toMatchObject({ + kind: 'rejected', rejection: { code: 'invalidRequest', message: expect.stringContaining('outside the daemon workspace') } + }); + expect(input.read().toString()).toBe('untouched'); + expect(result.stdout.length).toBe(0); + }); + + it('pins physical request identity when an alias changes while waiting for admission', async () => { + fixture.write('retargeted/projects/a/package.json', JSON.stringify({ + name: 'retargeted', version: '1.0.0', scripts: { build: 'node script.cjs' } + })); + fixture.write('retargeted/projects/a/script.cjs', "require('node:fs').writeFileSync('executed', 'wrong project');"); + let started: () => void = () => {}; + let release: () => void = () => {}; + const holding: Promise = new Promise((resolve) => { started = resolve; }); + const released: Promise = new Promise((resolve) => { release = resolve; }); + const holdAsync = async (): Promise<{ exitCode: number }> => { + started(); + await released; + return { exitCode: 0 }; + }; + jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync').mockResolvedValue({ + kind: 'global', executor: holdAsync + }); + const holder = fixture.runAsync({ + ...fixture.request(['hold'], path.join(physicalRoot, 'projects/a')), invocationKind: 'rush' + }); + await holding; + const input: PassThrough = new PassThrough(); + input.end('not consumed before execution'); + try { + const result = await fixture.runAsync( + fixture.request(['-q', 'build'], path.join(aliasRoot, 'projects/a')), undefined, { + stdin: input, + onQueuePositionAsync: async () => { + fs.unlinkSync(aliasRoot); + fs.symlinkSync(path.join(physicalRoot, 'retargeted'), aliasRoot, + process.platform === 'win32' ? 'junction' : 'dir'); + release(); + } + } + ); + if (process.platform === 'win32') { + expect(result.outcome).toMatchObject({ + kind: 'result', result: { exitCode: 1, errorMessage: expect.stringContaining('invocation directory changed') } + }); + expect(input.read().toString()).toBe('not consumed before execution'); + } else { + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout.toString()).cwd).toBe(path.join(physicalRoot, 'projects/a')); + } + expect(fs.existsSync(path.join(physicalRoot, 'retargeted/projects/a/executed'))).toBe(false); + } finally { + release(); + await holder; + } + }); + + it('falls back before input for relative temporary-folder initialization instead of using server cwd', async () => { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result = await fixture.runAsync(fixture.request( + ['build'], path.join(aliasRoot, 'projects/a'), fixture.environment({ RUSH_TEMP_FOLDER: 'relative-temp' }) + ), undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ + kind: 'fallback', reason: 'unsupported', message: expect.stringContaining('Relative RUSH_TEMP_FOLDER') + }); + expect(input.read().toString()).toBe('untouched'); + expect(result.stdout.length).toBe(0); + }); +}); diff --git a/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts b/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts new file mode 100644 index 00000000000..6fc6f42c1c1 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts @@ -0,0 +1,228 @@ +// 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 { PassThrough } from 'node:stream'; + +import { DaemonClient } from '@rushstack/rush-client-core'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import { RushXDaemonTestFixture, type IRequestResult, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx execution boundaries', () => { + let fixture: RushXDaemonTestFixture; + + afterEach(async () => { + await fixture?.[Symbol.asyncDispose](); + }); + + async function startAsync(hooks: boolean = false, pnpmSync: boolean = false): Promise { + fixture = new RushXDaemonTestFixture(hooks, pnpmSync); + await fixture.startAsync(); + return path.join(fixture.folder, 'projects/a'); + } + + it('rejects active hooks before execution/input, then native CLI fallback runs both hooks and the pipe once', async () => { + const cwd: string = await startAsync(true); + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result: IRequestResult = await fixture.runAsync(fixture.request(['pipe'], cwd), undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(input.read().toString()).toBe('untouched'); + expect(fs.existsSync(path.join(fixture.folder, 'hooks.txt'))).toBe(false); + const fallback: IScriptResult = await fixture.invokeAsync(false, ['-q', 'pipe'], cwd, undefined, Buffer.from('piped')); + expect(fallback.exitCode).toBe(0); + expect(fallback.stdout.toString()).toContain('piped'); + expect(fallback.stderr.toString()).toContain('using in-process Rush'); + expect(fs.readFileSync(path.join(fixture.folder, 'hooks.txt'), 'utf8')).toBe('hook\nhook\n'); + }); + + it('reuses native ignored and recursive hook behavior, including failure skipping post hooks', async () => { + const cwd: string = await startAsync(true); + for (const script of ['args', 'fail']) { + const argv: string[] = ['--ignore-hooks', script]; + expect(await fixture.invokeAsync(false, argv, cwd)).toEqual(await fixture.invokeAsync(true, argv, cwd)); + } + const environment = fixture.environment({ _RUSH_RECURSIVE_RUSHX_CALL: '1' }); + expect(await fixture.invokeAsync(false, ['args'], cwd, environment)) + .toEqual(await fixture.invokeAsync(true, ['args'], cwd, environment)); + expect(fs.existsSync(path.join(fixture.folder, 'hooks.txt'))).toBe(false); + }); + + it('executes the governing nested package and its real node_modules/.bin executable', async () => { + const cwd: string = await startAsync(); + fixture.write('projects/a/subfolder/package.json', JSON.stringify({ + name: 'nested', version: '1.0.0', scripts: { bin: 'fixture-bin' } + })); + const bin: string = process.platform === 'win32' ? 'fixture-bin.cmd' : 'fixture-bin'; + fixture.write(`projects/a/subfolder/node_modules/.bin/${bin}`, + process.platform === 'win32' ? '@echo path-ok\r\n' : '#!/bin/sh\nprintf "path-ok\\n"\n'); + fs.chmodSync(path.join(cwd, 'subfolder/node_modules/.bin', bin), 0o755); + const nested: string = path.join(cwd, 'subfolder'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'bin'], nested); + expect(await fixture.invokeAsync(false, ['-q', 'bin'], nested)).toEqual(native); + expect(native.exitCode).toBe(0); + expect(native.stdout.toString().trim()).toBe('path-ok'); + }); + + it('does real injected-dependency synchronization with native quiet/debug behavior', async () => { + const cwd: string = await startAsync(false, true); + fixture.write('projects/a/node_modules/.pnpm-sync.json', JSON.stringify({ + version: '0.3.4', + postbuildInjectedCopy: { sourceFolder: '..', targetFolders: [{ folderPath: '../../../injected/a' }] } + })); + fixture.write('injected/a/output.txt', 'old-content'); + for (const argv of [['-q', 'sync'], ['-d', 'sync']]) { + const native: IScriptResult = await fixture.invokeAsync(true, argv, cwd); + const daemon: IScriptResult = await fixture.invokeAsync(false, argv, cwd); + const normalize = (value: Buffer): string => value.toString().replace(/Synced (\d+ files?) in \d+ ms/g, 'Synced $1 in N ms'); + expect({ code: daemon.exitCode, out: normalize(daemon.stdout), err: normalize(daemon.stderr) }) + .toEqual({ code: native.exitCode, out: normalize(native.stdout), err: normalize(native.stderr) }); + expect(daemon.exitCode).toBe(0); + expect(fs.readFileSync(path.join(fixture.folder, 'injected/a/output.txt'), 'utf8')).toBe('new-content'); + expect(fs.statSync(path.join(fixture.folder, 'injected/a/output.txt')).ino) + .toBe(fs.statSync(path.join(cwd, 'output.txt')).ino); + } + }); + + it('reads changed scripts and dotenv files without retaining a fake warm script engine', async () => { + const cwd: string = await startAsync(); + expect((await fixture.runAsync(fixture.request(['-q', 'build'], cwd))).exitCode).toBe(0); + fixture.write('projects/a/script.cjs', 'console.log(process.env.REPO_VALUE);'); + fixture.write('.env', 'REPO_VALUE=changed\n'); + const result: IRequestResult = await fixture.runAsync(fixture.request(['-q', 'build'], cwd)); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toBe('changed\n'); + expect(fixture.session.operationGraph).toBeUndefined(); + }); + + it.each(['hooks', 'experiments', 'dotenv-configuration', 'vault', 'invalid-quiet'])( + 'rejects unsupported %s before consuming stdin or executing', async (kind) => { + const cwd: string = await startAsync(); + const environment = fixture.environment(); + if (kind === 'hooks') { + const file: string = path.join(fixture.folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + fixture.write('rush.json', JSON.stringify({ ...config, eventHooks: { preRushx: ['node hook.cjs'] } })); + } else if (kind === 'experiments') { + fixture.write('common/config/rush/experiments.json', '{"usePnpmSyncForInjectedDependencies":true}'); + } else if (kind === 'dotenv-configuration') { + fixture.write('.env', `RUSH_TEMP_FOLDER=${fixture.folder}/alternate-temp\n`); + } else if (kind === 'vault') { + environment.DOTENV_KEY = 'unsupported-vault'; + } else { + environment.RUSH_QUIET_MODE = 'invalid'; + } + const stdin: PassThrough = new PassThrough(); + stdin.end('not-read'); + const result = await fixture.runAsync(fixture.request(['build'], cwd, environment), undefined, { stdin }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(stdin.read().toString()).toBe('not-read'); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + } + ); + + it('fails visibly without running or reading when hooks change during queue admission', async () => { + const cwd: string = await startAsync(); + let release: () => void = () => {}; + let started: () => void = () => {}; + const released: Promise = new Promise((resolve) => { release = resolve; }); + const holding: Promise = new Promise((resolve) => { started = resolve; }); + const holdAsync = async (): Promise<{ exitCode: number }> => { + started(); + await released; + return { exitCode: 0 }; + }; + jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync').mockResolvedValue({ + kind: 'global', executor: holdAsync + }); + const holder = fixture.runAsync({ ...fixture.request(['hold'], cwd), invocationKind: 'rush' }); + await holding; + const stdin: PassThrough = new PassThrough(); + stdin.end('not-consumed'); + try { + const result = await fixture.runAsync(fixture.request(['build'], cwd), undefined, { + stdin, + onQueuePositionAsync: async () => { + const file: string = path.join(fixture.folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + fixture.write('rush.json', JSON.stringify({ ...config, eventHooks: { preRushx: ['node hook.cjs'] } })); + release(); + } + }); + expect(result.outcome).toMatchObject({ kind: 'result', result: { exitCode: 1, outcome: 'failure' } }); + expect(result.stderr.toString()).toContain('Rush configuration changed'); + expect(stdin.read().toString()).toBe('not-consumed'); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + } finally { + release(); + await holder; + } + }); + + it('does not guess package scripts for legacy or custom workspace requests', async () => { + const cwd: string = await startAsync(); + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + for (const invocationKind of [undefined, 'rush'] as const) { + const request: IDaemonRequestEnvelope = { ...fixture.request(['build'], cwd), invocationKind }; + expect((await fixture.runAsync(request)).outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + } + expect(phased).toHaveBeenCalledTimes(2); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + }); + + it('cancels the owned script and descendant before publishing the authoritative result', async () => { + const cwd: string = await startAsync(); + const controller: AbortController = new AbortController(); + let output: string = ''; + const result = await fixture.runAsync(fixture.request(['-q', 'tree'], cwd), undefined, { + abortSignal: controller.signal, + onStdoutAsync: async (bytes) => { + output += Buffer.from(bytes).toString(); + if (output.includes('PARENT:') && output.includes('DESCENDANT:')) controller.abort(); + } + }); + expect(result.outcome).toMatchObject({ kind: 'result', result: { exitCode: 1, aborted: true, outcome: 'aborted' } }); + assertChildrenStopped(output); + expect((await fixture.runAsync(fixture.request(['-q', 'args'], cwd))).exitCode).toBe(0); + }); + + it('cleans disconnected children without retrying the request', async () => { + const cwd: string = await startAsync(); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: fixture.host.paths.socketPath }); + let output: string = ''; + await expect(client.executeAsync({ + request: fixture.request(['-q', 'tree'], cwd), + onStdoutAsync: async (bytes) => { + output += Buffer.from(bytes).toString(); + if (output.includes('PARENT:') && output.includes('DESCENDANT:')) await client.closeAsync(); + }, + onStderrAsync: async () => {} + })).rejects.toThrow('not retried'); + await fixture.host.closeAsync(); + assertChildrenStopped(output); + }); +}); + +function assertChildrenStopped(output: string): void { + const pids: number[] = Array.from(output.matchAll(/(?:PARENT|DESCENDANT):(\d+)/g), (match) => Number(match[1])); + expect(pids).toHaveLength(2); + for (const pid of pids) { + try { + process.kill(pid, 0); + // Linux containers may retain a reparented zombie until init reaps it; it cannot execute or own streams. + if (process.platform === 'linux') { + expect(fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].startsWith('Z ')).toBe(true); + } else { + throw new Error(`Script descendant ${pid} is still running.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} diff --git a/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts b/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts new file mode 100644 index 00000000000..5d3457cf2e0 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; + +import { Rush } from '@microsoft/rush-lib'; +import { + DaemonClient, + captureDaemonRequest, + type DaemonClientOutcome, + type IDaemonClientExecuteOptions +} from '@rushstack/rush-client-core'; +import { + ProductionDaemonRequestResolver, + RushDaemonHost, + RushDaemonRequestResolver, + WorkspaceSession +} from '@rushstack/rush-daemon'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +export interface IScriptResult { + readonly exitCode: number | undefined; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +export interface IRequestResult extends IScriptResult { + readonly outcome: DaemonClientOutcome; +} + +export class RushXDaemonTestFixture implements AsyncDisposable { + public readonly folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushx-daemon-')); + public readonly home: string = path.join(this.folder, 'home'); + public readonly phasedResolver: ProductionDaemonRequestResolver = new ProductionDaemonRequestResolver(); + public readonly errors: Error[] = []; + public host!: RushDaemonHost; + public session!: WorkspaceSession; + + public constructor(hooks: boolean = false, pnpmSync: boolean = false) { + this.write('rush.json', JSON.stringify({ + rushVersion: Rush.version, + suppressNodeLtsWarning: true, + pnpmVersion: '10.27.0', + projectFolderMinDepth: 2, + projectFolderMaxDepth: 2, + daemon: { enabled: true, autoStart: false }, + eventHooks: hooks ? { preRushx: ['node hook.cjs'], postRushx: ['node hook.cjs'] } : {}, + projects: ['a', 'b'].map((name) => ({ packageName: name, projectFolder: `projects/${name}` })) + })); + this.write('hook.cjs', "require('node:fs').appendFileSync('hooks.txt', 'hook\\n');"); + this.write('common/config/rush/experiments.json', JSON.stringify({ + usePnpmSyncForInjectedDependencies: pnpmSync + })); + this.write('home/.rush-user/.env', 'USER_VALUE=from-user\nORDER=from-user\n'); + this.write('.env', 'REPO_VALUE=from-repo\nORDER=from-repo\nCLIENT_VALUE=from-repo\n'); + for (const name of ['a', 'b', 'unregistered']) { + this.write(`projects/${name}/package.json`, JSON.stringify({ + name, version: '1.0.0', + scripts: { + build: 'node script.cjs', + args: 'node args.cjs', + pipe: 'node pipe.cjs', + fail: 'node fail.cjs', + early: 'node early.cjs', + tree: 'node tree.cjs', + sync: 'node sync.cjs', + daemon: 'node args.cjs' + } + })); + this.write(`projects/${name}/subfolder/keep.txt`, ''); + this.write(`projects/${name}/script.cjs`, ` +const fs = require('node:fs'); +const path = require('node:path'); +fs.appendFileSync('runs.txt', 'ran\\n'); +console.log(JSON.stringify({ + name: require('./package.json').name, cwd: process.cwd(), + invoked: process.env.RUSH_INVOKED_FOLDER, init: process.env.INIT_CWD, + bin: process.env.PATH.split(path.delimiter)[0], marker: process.env.CLIENT_MARKER, + repo: process.env.REPO_VALUE, user: process.env.USER_VALUE, + order: process.env.ORDER, client: process.env.CLIENT_VALUE, + recursive: process.env._RUSH_RECURSIVE_RUSHX_CALL, + npmConfig: process.env.NPM_CONFIG_TEST, + absent: process.env.RUSHD_PARENT_ONLY +})); +`); + this.write(`projects/${name}/args.cjs`, 'process.stdout.write(JSON.stringify(process.argv.slice(2)));'); + this.write(`projects/${name}/pipe.cjs`, ` +process.stderr.write(Buffer.from([255, 0, 3, 10])); +process.stdin.pipe(process.stdout); +`); + this.write(`projects/${name}/fail.cjs`, + "process.stdout.write('raw-out'); process.stderr.write('raw-err'); process.exitCode = 7;"); + this.write(`projects/${name}/early.cjs`, 'process.exitCode = 23;'); + this.write(`projects/${name}/sync.cjs`, "require('node:fs').writeFileSync('output.txt', 'new-content');"); + this.write(`projects/${name}/tree.cjs`, ` +const { spawn } = require('node:child_process'); +spawn(process.execPath, ['-e', "console.log('DESCENDANT:' + process.pid); setInterval(() => {}, 1000);"], + { stdio: ['ignore', 'inherit', 'inherit'] }); +console.log('PARENT:' + process.pid); +setInterval(() => {}, 1000); +`); + } + } + + public async startAsync(): Promise { + const { version }: { version: string } = require('@rushstack/rush-daemon/package.json'); + this.host = await RushDaemonHost.startAsync({ + repoRoot: this.folder, + rushVersion: Rush.version, + daemonVersion: version, + requestResolver: new RushDaemonRequestResolver(this.phasedResolver), + onError: (error) => { this.errors.push(error); }, + createWorkspaceSessionAsync: async (options) => { + this.session = await WorkspaceSession.createAsync(options); + return this.session; + } + }); + } + + public write(filename: string, content: string): void { + const fullPath: string = path.join(this.folder, filename); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + public environment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, HOME: this.home, USERPROFILE: this.home, + CLIENT_MARKER: 'client', CLIENT_VALUE: 'from-client', + NPM_CONFIG_TEST: 'discard-me', INIT_CWD: 'discard-me', + RUSHD_PARENT_ONLY: undefined, FORCE_COLOR: '0', NO_COLOR: undefined, + _RUSH_RECURSIVE_RUSHX_CALL: undefined, + RUSH_DAEMON: '1', RUSH_DAEMON_AUTO_START: '0', + CI: 'false', TF_BUILD: 'false', GITHUB_ACTIONS: 'false', + ...overrides + }; + } + + public request(argv: string[], cwd: string, environment: NodeJS.ProcessEnv = this.environment()): IDaemonRequestEnvelope { + return captureDaemonRequest({ + argv, commandName: argv.find((arg) => !arg.startsWith('-'))!, + commandOrigin: 'custom', invocationKind: 'rushx', cwd, environment, + terminal: { isTTY: false, supportsColor: false, acceptsStdin: true } + }); + } + + public async runAsync( + request: IDaemonRequestEnvelope, + input: Buffer = Buffer.alloc(0), + overrides: Partial = {} + ): Promise { + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: this.host.paths.socketPath }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const outcome: DaemonClientOutcome = await client.executeAsync({ + request, stdin: Readable.from([input]), requiresStdinEnd: true, + onStdoutAsync: async (bytes) => { stdout.push(Buffer.from(bytes)); }, + onStderrAsync: async (bytes) => { stderr.push(Buffer.from(bytes)); }, + ...overrides + }); + return { + outcome, exitCode: outcome.kind === 'result' ? outcome.result.exitCode : undefined, + stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) + }; + } + + public invokeAsync( + native: boolean, + argv: string[], + cwd: string, + environment: NodeJS.ProcessEnv = this.environment(), + input: Buffer = Buffer.alloc(0) + ): Promise { + const entry: string = native + ? path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx') + : path.resolve(__dirname, '../../bin/rushx-client'); + const child: ChildProcess = spawn(process.execPath, [entry, ...argv], { + cwd, env: environment, stdio: 'pipe' + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout!.on('data', (bytes: Buffer) => stdout.push(bytes)); + child.stderr!.on('data', (bytes: Buffer) => stderr.push(bytes)); + child.stdin!.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE' && !(process.platform === 'win32' && error.code === 'EOF')) throw error; + }); + child.stdin!.end(input); + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => resolve({ + exitCode: exitCode ?? undefined, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) + })); + }); + } + + public async [Symbol.asyncDispose](): Promise { + await this.host?.closeAsync(); + fs.rmSync(this.folder, { recursive: true, force: true }); + } +} diff --git a/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts new file mode 100644 index 00000000000..c4f21097593 --- /dev/null +++ b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts @@ -0,0 +1,50 @@ +// 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 os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { computeDaemonWorkspaceKey, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions, getDaemonConnectionOptionsAsync } from '../daemonConnectionOptions'; + +describe('version-selected daemon connection options', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-connection-')); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: Rush.version })); + }); + afterEach(() => fs.rmSync(repoRoot, { recursive: true })); + + it('uses the same native filesystem identity as the daemon host', async () => { + const nativeRoot: string = await fs.promises.realpath(repoRoot); + const options = getDaemonConnectionOptions(repoRoot, Rush.version, process.env, true); + expect(options.paths).toEqual(resolveDaemonPathsFromProcess(computeDaemonWorkspaceKey({ + canonicalRepoRoot: nativeRoot, rushVersion: Rush.version + }))); + expect(options.startCommand?.cwd).toBe(nativeRoot); + }); + + it('creates an attested async launcher without changing current synchronous callers', async () => { + const synchronous = getDaemonConnectionOptions(repoRoot, Rush.version, process.env, true); + const asynchronous = await getDaemonConnectionOptionsAsync(repoRoot, Rush.version, process.env, true); + expect(asynchronous.paths).toEqual(synchronous.paths); + expect(asynchronous.expectedDaemonVersion).toBe(synchronous.expectedDaemonVersion); + expect(asynchronous.startCommand?.args).toEqual(synchronous.startCommand?.args); + }); + + it('does not claim a different requested engine in the synchronous default launcher', () => { + expect(() => getDaemonConnectionOptions(repoRoot, '5.178.1', {}, true)).toThrow( + 'asynchronous version selection' + ); + }); + + it('does not select or install a launcher for a connect-only invocation', async () => { + const options = await getDaemonConnectionOptionsAsync(repoRoot, '5.178.1', {}, false); + expect(options.startCommand).toBeUndefined(); + expect(options.expectedDaemonVersion).toBeUndefined(); + }); +}); diff --git a/apps/rush-cli-client/src/test/daemonGraph.test.ts b/apps/rush-cli-client/src/test/daemonGraph.test.ts new file mode 100644 index 00000000000..8d76b245a18 --- /dev/null +++ b/apps/rush-cli-client/src/test/daemonGraph.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { + createDaemonHelloAck, DAEMON_PROTOCOL_VERSION, DaemonFrameType, + decodeDaemonControlMessage, encodeDaemonControlMessage, encodeDaemonEventFrame, + RUSHD_GRAPH_SNAPSHOT, type DaemonControlMessage +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +describe('graph client fails closed over the public wire', () => { + it.each(['unsupported', 'missing-snapshot', 'invalid-snapshot', 'old-protocol'])( + 'rejects %s without invoking native Rush', + async (mode) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-graph-peer-')); + const peers: Set = new Set(); + let listener: DaemonFrameListener | undefined; + let requests: number = 0; + try { + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: Rush.version, npmVersion: '10.0.0', projects: [] + })); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, process.env, false); + const protocolVersion = mode === 'old-protocol' ? { major: 0, minor: 1 } : DAEMON_PROTOCOL_VERSION; + listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion, + onConnection: (connection) => { + peers.add(connection); + connection.onClosed(() => { peers.delete(connection); }); + const send = (message: DaemonControlMessage): Promise => connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(message) + }); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await send(createDaemonHelloAck(protocolVersion, 'old-graph-peer')); + } else if (message.kind === 'ping') { + await send({ kind: 'pong', payload: { uptimeMs: 1 } }); + } else if (message.kind === 'requestStart') { + requests++; + expect(message.payload).toMatchObject({ + commandOrigin: 'built-in', commandName: 'daemon', argv: ['daemon', 'graph', 'show'] + }); + const requestId: string = message.payload.requestId; + if (mode === 'unsupported') { + await send({ + kind: 'requestRejected', payload: { requestId, code: 'unsupported', message: 'No graph route.' } + }); + return; + } + if (mode === 'invalid-snapshot') { + await connection.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion, eventId: 'bad-event', sessionId: 'old-graph-peer', sequence: 1, + timestamp: new Date().toISOString(), + source: { packageName: '@rushstack/rush-daemon', packageVersion: '0.0.0' }, + type: 'extension', privacy: 'local-sensitive', required: true, + payload: { name: RUSHD_GRAPH_SNAPSHOT, data: { requestId, snapshot: { initialized: 'invalid' } } } + }) + }); + return; + } + await send({ + kind: 'requestResult', payload: { requestId, aborted: false, exitCode: 0, outcome: 'success' } + }); + } + }); + } + }); + const child = spawn( + process.execPath, + [path.resolve(__dirname, '../../bin/rush-client'), 'daemon', 'graph', 'show'], + { cwd: folder, env: { ...process.env, RUSH_DAEMON_EXPERIMENTAL: '1' }, stdio: ['ignore', 'pipe', 'pipe'] } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { stdout += bytes.toString(); }); + child.stderr.on('data', (bytes: Buffer) => { stderr += bytes.toString(); }); + expect((await once(child, 'close'))[0]).toBe(1); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ kind: 'graphError', message: expect.any(String) }); + expect(stdout).not.toMatch(/using in-process|Usage: rush /); + expect(requests).toBe(mode === 'old-protocol' ? 0 : 1); + } finally { + await Promise.all(Array.from(peers, (peer) => peer.closeAsync())); + await listener?.closeAsync(); + fs.rmSync(folder, { recursive: true, force: true }); + } + }, + 15000 + ); +}); diff --git a/apps/rush-cli-client/src/test/launchClient.test.ts b/apps/rush-cli-client/src/test/launchClient.test.ts new file mode 100644 index 00000000000..0cbbfab9494 --- /dev/null +++ b/apps/rush-cli-client/src/test/launchClient.test.ts @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as http from 'node:http'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { DaemonClient, connectOrStartDaemonAsync, getDaemonLogFilePath } from '@rushstack/rush-client-core'; +import { RushDaemonHost, WorkspaceSession } from '@rushstack/rush-daemon'; +import { removeTestFolderAsync, waitForTestProcessExitAsync } from '@rushstack/rush-daemon/lib/test/TestProcessExit'; +import { readDaemonLockfile, removeDaemonArtifacts } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +interface IInvocationResult { + readonly code: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +describe('standalone rushx fallback', () => { + let folder: string; + let project: string; + let logFilePath: string; + let host: RushDaemonHost | undefined; + let invocationClosures: Promise[]; + let daemonPids: Set; + + beforeEach(() => { + invocationClosures = []; + daemonPids = new Set(); + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-cli-client-')); + project = path.join(folder, 'project'); + fs.mkdirSync(project); + fs.mkdirSync(path.join(folder, 'common/config/rush'), { recursive: true }); + fs.writeFileSync( + path.join(folder, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: '10.27.0', + daemon: { enabled: false, autoStart: false }, + projects: [{ packageName: 'sample', projectFolder: 'project' }], + projectFolderMinDepth: 1 + }) + ); + logFilePath = getDaemonLogFilePath(getDaemonConnectionOptions(folder, Rush.version, {}, false).paths); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'sample', + version: '1.0.0', + scripts: { + sample: + 'node -e "process.stdout.write(process.env.CLIENT_MARKER); process.stderr.write(\'script-error\'); process.exitCode=7;"' + } + }) + ); + }); + + afterEach(async () => { + const closingHost: RushDaemonHost | undefined = host; + const closingFolder: string = folder; + const closingLogFilePath: string = logFilePath; + const closingDaemonPids: Set = daemonPids; + host = undefined; + await Promise.all(invocationClosures); + await closingHost?.closeAsync(); + await Promise.all(Array.from(closingDaemonPids, (pid) => waitForTestProcessExitAsync(pid))); + await removeTestFolderAsync(closingLogFilePath, true); + await removeTestFolderAsync(closingFolder); + }); + + async function invokeAsync( + client: boolean, + optIn: boolean, + fakeTty: boolean = false, + managementArgs?: ReadonlyArray, + environmentOverrides: NodeJS.ProcessEnv = {} + ): Promise { + const invocationDaemonPids: Set = daemonPids; + const entry: string = client + ? path.resolve(__dirname, managementArgs ? '../../bin/rush-client' : '../../bin/rushx-client') + : path.resolve(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx'); + const args: string[] = fakeTty + ? [ + '-e', + `Object.defineProperty(process.stdin, 'isTTY', { value: true }); process.argv = [process.execPath, ${JSON.stringify(entry)}, 'sample']; require(${JSON.stringify(entry)});` + ] + : [entry, ...(managementArgs ?? [...(client ? ['--no-daemon'] : []), 'sample'])]; + const child: ChildProcess = spawn(process.execPath, args, { + cwd: project, + env: { + ...process.env, + CLIENT_MARKER: 'script-output', + RUSH_DAEMON: optIn ? '1' : '0', + CI: managementArgs ? 'true' : 'false', + TF_BUILD: 'false', + GITHUB_ACTIONS: 'false', + ...environmentOverrides + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout!.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed: Promise = once(child, 'close'); + invocationClosures.push(closed); + const [code] = await closed; + if (code === 0 && managementArgs?.[0] === 'daemon' && + (managementArgs[1] === 'start' || managementArgs[1] === 'restart')) { + const { pid }: { pid: unknown } = JSON.parse(stdout); + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) { + throw new Error('The started fixture daemon did not report a valid PID.'); + } + if (pid !== process.pid) invocationDaemonPids.add(pid); + } + return { code: typeof code === 'number' ? code : undefined, stdout, stderr }; + } + + it('preserves native project-script output and exit code for --no-daemon', async () => { + const native: IInvocationResult = await invokeAsync(false, false); + expect(native.code).toBe(7); + expect(await invokeAsync(true, true)).toEqual(native); + }, 15000); + + it('falls back after the actual standalone host rejects an unsupported request', async () => { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + const native: IInvocationResult = await invokeAsync(false, false); + const client: IInvocationResult = await invokeAsync(true, true, true); + expect(client.code).toBe(native.code); + expect(client.stdout).toBe(native.stdout); + expect(client.stderr).toContain('using in-process Rush'); + expect(client.stderr).toContain(native.stderr); + }, 15000); + + it('starts idempotently and reports real readiness in CI without execution opt-in', async () => { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + for (const verb of ['start', 'start', 'status']) { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', verb]); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + const status: Record = JSON.parse(result.stdout); + expect(status).toMatchObject({ + state: 'ready', + socketPath: host.paths.socketPath, + daemonVersion: daemonPackage.version + }); + expect(status.uptimeMs).toEqual(expect.any(Number)); + expect(status.pid).toBe(process.pid); + expect(status.residentMemoryBytes).toEqual(expect.any(Number)); + expect(status).not.toHaveProperty('warmProjects'); + } + }, 15000); + + it('status never starts an absent daemon or falls back to command execution', async () => { + const result: IInvocationResult = await invokeAsync(true, true, false, ['daemon', 'status']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Could not connect to daemon'); + expect(result.stderr).not.toContain('using in-process'); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + }); + + it('starts an absent daemon that survives the client and then shuts down when idle', async () => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { enabled: false, autoStart: false, idleTimeoutSeconds: 2 } + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + try { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ state: 'ready', socketPath: paths.socketPath }); + expect(fs.existsSync(paths.lockfilePath)).toBe(true); + } finally { + const deadline: number = Date.now() + 7000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(50); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } + const stopped: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'status']); + expect(stopped.code).toBe(1); + const log: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'logs']); + expect(log.code).toBe(0); + expect(log.stdout).toContain('rushd ready at'); + }, 15000); + + it('reads a saved launcher log without connecting or auto-starting', async () => { + fs.mkdirSync(path.dirname(logFilePath), { recursive: true, mode: 0o700 }); + const contents: string = `${'startup output\n'.repeat(20000)}startup error\n`; + fs.writeFileSync(logFilePath, contents, { mode: 0o600 }); + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'logs']); + expect(result).toEqual({ code: 0, stdout: contents, stderr: '' }); + expect( + fs.existsSync(getDaemonConnectionOptions(folder, Rush.version, {}, false).paths.lockfilePath) + ).toBe(false); + }); + + it('returns an actionable missing-log error without creating a log or daemon', async () => { + const result: IInvocationResult = await invokeAsync(true, true, false, ['daemon', 'logs']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No launcher log exists'); + expect(result.stderr).toContain(logFilePath); + expect(fs.existsSync(logFilePath)).toBe(false); + }); + + it('accepts an empty launcher log and rejects unsupported follow arguments', async () => { + fs.mkdirSync(path.dirname(logFilePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(logFilePath, '', { mode: 0o600 }); + expect(await invokeAsync(true, false, false, ['daemon', 'logs'])).toEqual({ + code: 0, + stdout: '', + stderr: '' + }); + expect((await invokeAsync(true, false, false, ['daemon', 'logs', '--follow'])).code).toBe(1); + }); + + it('does not start an absent daemon when stop or restart cannot be acknowledged', async () => { + for (const verb of ['stop', 'restart']) { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', verb]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Could not connect to daemon'); + } + }); + + it.each([false, true])( + 'restarts after ownership release and stops the successor (embedded: %s)', + async (embedded) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { enabled: false, autoStart: false, idleTimeoutSeconds: 5 } + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + try { + if (embedded) { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + } else { + const started: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start']); + expect(started.code).toBe(0); + } + const originalLock: { startedAt: string } = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + const restarted: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'restart']); + expect(restarted.stderr).toBe(''); + expect(restarted.code).toBe(0); + expect(JSON.parse(restarted.stdout)).toMatchObject({ + state: 'ready', + pid: expect.any(Number), + residentMemoryBytes: expect.any(Number) + }); + const successorLock: { startedAt: string } = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + expect(successorLock.startedAt).not.toBe(originalLock.startedAt); + await host?.closeAsync(); + expect(fs.existsSync(paths.lockfilePath)).toBe(true); + const stopped: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'stop']); + expect(stopped.code).toBe(0); + expect(JSON.parse(stopped.stdout)).toMatchObject({ state: 'shutdownAccepted' }); + } finally { + const deadline: number = Date.now() + 7000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(50); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } + }, + 30000 + ); + + it('bounds restart after failed workspace cleanup without deleting live ownership', async () => { + const errors: Error[] = []; + const failedHost: RushDaemonHost = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: 'cleanup-failure-test', + onError: (error) => { + errors.push(error); + }, + createWorkspaceSessionAsync: async (sessionOptions) => + WorkspaceSession.createAsync({ + ...sessionOptions, + createComponentsAsync: async () => ({ + [Symbol.asyncDispose]: async () => { + throw new Error('workspace cleanup failed'); + } + }) + }) + }); + const originalRecord: string = fs.readFileSync(failedHost.paths.lockfilePath, 'utf8'); + const previousDaemon = readDaemonLockfile(failedHost.paths.lockfilePath)!; + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: failedHost.paths.socketPath }); + try { + await client.shutdownAsync(); + await failedHost.closed; + await expect(failedHost.closeAsync()).rejects.toThrow('workspace cleanup failed'); + expect(errors).toHaveLength(1); + await expect( + connectOrStartDaemonAsync({ + ...getDaemonConnectionOptions(folder, Rush.version, {}, true), + previousDaemon, + startupTimeoutMs: 40 + }) + ).rejects.toThrow('previous daemon still owns'); + expect(fs.readFileSync(failedHost.paths.lockfilePath, 'utf8')).toBe(originalRecord); + } finally { + await client.closeAsync(); + removeDaemonArtifacts(failedHost.paths.lockfilePath, failedHost.paths.socketPath); + } + }); + + it('rejects extra management arguments without silently ignoring them', async () => { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'status', 'extra']); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Usage: rush-client daemon start|status'); + }); + + it('does not guess a daemon launcher for a different selected Rush version', async () => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync(rushJsonPath, JSON.stringify({ ...config, rushVersion: '0.0.0' })); + let registryRequests: number = 0; + const registry = http.createServer((request, response) => { + registryRequests++; + request.resume(); + response.writeHead(404, { 'content-type': 'application/json', connection: 'close' }); + response.end(JSON.stringify({ error: 'no supported release' })); + }); + await new Promise((resolve) => registry.listen(0, '127.0.0.1', resolve)); + try { + const address = registry.address(); + if (!address || typeof address === 'string') throw new Error('Expected a local registry address.'); + const registryUrl: string = `http://127.0.0.1:${address.port}`; + // Native Rush subprocesses discard NPM_CONFIG_* overrides. + fs.writeFileSync( + path.join(folder, 'common/config/rush/.npmrc'), + `registry=${registryUrl}\ncache=${path.join(folder, 'npm-cache')}\naudit=false\nfund=false\n` + ); + const home: string = path.join(folder, 'home'); + fs.mkdirSync(home); + fs.writeFileSync(path.join(home, '.npmrc'), ''); + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start'], { + HOME: home, + USERPROFILE: home, + RUSH_GLOBAL_FOLDER: path.join(folder, 'global') + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Cannot launch selected Rush 0.0.0'); + expect(result.stdout).toBe(''); + expect(registryRequests).toBeGreaterThan(0); + expect(fs.existsSync(getDaemonConnectionOptions(folder, '0.0.0', {}, false).paths.lockfilePath)).toBe(false); + } finally { + await new Promise((resolve, reject) => registry.close((error) => error ? reject(error) : resolve())); + } + }, 15000); + + it('rejects explicit startup combined with --no-daemon', async () => { + const result: IInvocationResult = await invokeAsync(true, false, false, [ + 'daemon', + 'start', + '--no-daemon' + ]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--no-daemon cannot be combined with daemon start'); + }); +}); diff --git a/apps/rush-cli-client/src/test/nativeBuild.test.ts b/apps/rush-cli-client/src/test/nativeBuild.test.ts new file mode 100644 index 00000000000..2703d68a410 --- /dev/null +++ b/apps/rush-cli-client/src/test/nativeBuild.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { DaemonClient } from '@rushstack/rush-client-core'; +import { RUSHD_GRAPH_SNAPSHOT } from '@rushstack/rush-daemon-protocol'; +import { + createNativeBuildTestFixture, + type INativeBuildTestFixture, + type INativeBuildResult as IResult +} from './NativeBuildTestFixture'; + +// A pending CLI startup can take 15s; allow its join plus daemon stop/drain and fixture removal. +const NATIVE_FIXTURE_CLEANUP_TIMEOUT_MS: number = 35_000; + +describe('native build through the standalone client', () => { + let fixture: INativeBuildTestFixture | undefined; + beforeEach(() => { + fixture = undefined; + fixture = createNativeBuildTestFixture(); + }); + afterEach(async () => { + await fixture?.closeAsync(); + }, NATIVE_FIXTURE_CLEANUP_TIMEOUT_MS); + + function runWithFixtureAsync(work: (current: INativeBuildTestFixture) => Promise): Promise { + if (!fixture) throw new Error('The native build fixture was not initialized.'); + return fixture.runAsync(work); + } + + describe('after a successful initial native build', () => { + const argv: string[] = ['build', '--to', 'b', '--verbose']; + let firstPid: number | undefined; + beforeEach( + () => + runWithFixtureAsync(async ({ paths, invokeAsync }) => { + const first: IResult = await invokeAsync(argv); + expect(first.code).toBe(0); + expect(first.stderr).not.toMatch(/using in-process/i); + expect(first.stdout).toContain('built-a-one'); + expect(first.stdout).toContain('built-b-one'); + expect(first.stdout).toContain('==['); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: paths.socketPath }); + try { + firstPid = (await client.status).pid; + } finally { + await client.closeAsync(); + } + }), + 30000 + ); + + it( + 'executes selected scripts, reuses warm state, and never confuses rushx build with rush build', + () => + runWithFixtureAsync(async ({ folder, invokeAsync }) => { + expect((await invokeAsync(argv)).code).toBe(0); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + const native: IResult = await invokeAsync(['--no-daemon', ...argv]); + expect(native.code).toBe(0); + expect(native.stderr).not.toContain('Another Rush command'); + const beforeChange: string = fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8'); + fs.writeFileSync(path.join(folder, 'a/input.txt'), 'two'); + expect((await invokeAsync(argv)).code).toBe(0); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe( + `${beforeChange}a:two\nb:one\n` + ); + const status = await invokeAsync(['daemon', 'status']); + expect(JSON.parse(status.stdout).pid).toBe(firstPid); + const script: IResult = await invokeAsync(['build'], true); + expect(script.code).toBe(0); + expect(script.stderr).not.toMatch(/using in-process/i); + expect(script.stdout).toContain('rushx-only'); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe( + `${beforeChange}a:two\nb:one\n` + ); + }), + 30000 + ); + }); + + it( + 'gates graph commands and inspects an uninitialized daemon without running work', + () => + runWithFixtureAsync(async ({ folder, environment, paths, invokeAsync, snapshotAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '0'; + const disabled = await invokeAsync(['daemon', 'graph', 'show']); + expect(disabled.code).toBe(1); + expect(JSON.parse(disabled.stdout)).toMatchObject({ kind: 'graphError' }); + expect(disabled.stderr).toBe(''); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['daemon', 'start'])).code).toBe(0); + + expect(await snapshotAsync('show')).toMatchObject({ initialized: false }); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + expect((await invokeAsync(['daemon', 'graph', 'watch'])).code).toBe(1); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + }), + 30000 + ); + + it( + 'provides presentation-free graph controls without implicitly executing scheduled work', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync, snapshotAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b', '--parallelism', '3'])).code).toBe(0); + expect(await snapshotAsync('scope-out', '--project', 'a')).toMatchObject({ + operations: [{ enabled: false }, { enabled: false }] + }); + expect(await snapshotAsync('scope-in', '--operation', 'b (compile)')).toMatchObject({ + operations: [{ enabled: true }, { enabled: true, dependencyIds: ['a (compile)'] }] + }); + expect(await snapshotAsync('pause')).toMatchObject({ pauseNextIteration: true }); + expect(await snapshotAsync('invalidate', '--project', 'a')).toMatchObject({ + hasScheduledIteration: false, + operations: [{ status: 'READY' }, { status: 'SUCCESS' }] + }); + expect(await snapshotAsync('resume')).toMatchObject({ pauseNextIteration: false }); + expect(await snapshotAsync('status')).toMatchObject({ initialized: true }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + }), + 30000 + ); + + it( + 'rejects invalid graph commands without native fallback or executing work', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + for (const args of [['invalid'], ['scope-out', '--project', 'missing'], ['pause', 'invalid']]) { + const result = await invokeAsync(['daemon', 'graph', ...args]); + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ kind: 'requestRejected' }); + } + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + }), + 30000 + ); + + it( + 'streams graph changes and gracefully cancels through the CLI signal handler', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync, snapshotAsync, trackWatch }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const entry: string = path.resolve( + __dirname, + process.platform === 'win32' ? 'CliSignalTestProcess.js' : '../../bin/rush-client' + ); + const watch = spawn(process.execPath, [entry, 'daemon', 'graph', 'watch'], { + cwd: folder, + env: environment, + stdio: process.platform === 'win32' ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'] + }); + const closed = once(watch, 'close'); + trackWatch(watch, closed); + let output: string = ''; + let errors: string = ''; + watch.stderr!.on('data', (bytes: Buffer) => { + errors += bytes.toString(); + }); + const ready = new Promise((resolve) => { + watch.stdout!.on('data', (bytes: Buffer) => { + output += bytes.toString(); + if (output.includes('\n')) resolve(); + }); + }); + try { + await Promise.race([ + ready, + closed.then(() => { + throw new Error(`Graph watch exited before its first snapshot: ${errors}`); + }) + ]); + expect(await snapshotAsync('scope-out', '--project', 'a')).toMatchObject({ + operations: [{ enabled: false }, { enabled: false }] + }); + if (process.platform === 'win32') watch.send('SIGINT'); + else watch.kill('SIGINT'); + expect(await closed).toEqual([130, null]); + expect(errors).toBe(''); + const records = output + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records[0]).toMatchObject({ type: 'extension', payload: { name: RUSHD_GRAPH_SNAPSHOT } }); + expect(records.at(-1)).toMatchObject({ + kind: 'requestResult', + payload: { outcome: 'aborted', exitCode: 130 } + }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + } finally { + if (watch.exitCode === null && watch.signalCode === null) watch.kill('SIGTERM'); + await closed; + } + }), + 30000 + ); + + describe('after observing cold and warm policy through CLI status', () => { + let previousPid: number; + let previousGeneration: number; + let previousGenerationToken: string; + beforeEach( + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + delete environment.RUSH_DAEMON_WATCH; + delete environment.RUSH_DAEMON_WARM_SET_MAX_PROJECTS; + const started: IResult = await invokeAsync(['daemon', 'start']); + expect(started.code).toBe(0); + const cold = JSON.parse(started.stdout); + expect(cold.workspace).toMatchObject({ + graphInitialized: false, + generationToken: expect.any(String), + lastReloadTier: 0 + }); + expect(cold.workspace.warmSet).toBeUndefined(); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const warmStatus: IResult = await invokeAsync(['daemon', 'status']); + expect(warmStatus.code).toBe(0); + const warm = JSON.parse(warmStatus.stdout); + expect(warm.workspace).toMatchObject({ + graphInitialized: true, + warmSet: { + configuration: { watch: false }, + maintenanceState: 'running', + watchedProjectNames: [], + retainedProjectNames: expect.any(Array), + daemonResidentMemoryBytes: expect.any(Number) + } + }); + previousPid = warm.pid; + previousGeneration = warm.workspace.generation; + previousGenerationToken = warm.workspace.generationToken; + }), + 30000 + ); + + it( + 'reports live warm policy and reload generations through ordinary CLI status without running work', + () => + runWithFixtureAsync(async ({ folder, invokeAsync }) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { ...config.daemon, watch: true, warmSetMaxProjects: 1 } + }) + ); + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const runsBefore: string = fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8'); + const reloadedStatus: IResult = await invokeAsync(['daemon', 'status']); + expect(reloadedStatus.code).toBe(0); + const reloaded = JSON.parse(reloadedStatus.stdout); + expect(reloaded.pid).toBe(previousPid); + expect(reloaded.workspace.generation).toBeGreaterThan(previousGeneration); + expect(reloaded.workspace.generationToken).not.toBe(previousGenerationToken); + expect(reloaded.workspace).toMatchObject({ + lastReloadTier: 1, + graphInitialized: true, + warmSet: { configuration: { watch: true, warmSetMaxProjects: 1 } } + }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe(runsBefore); + }), + 30000 + ); + }); + + it( + 'runs the genuine preview-selected engine without rewriting the configured Rush version', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const configured = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + const original: string = JSON.stringify({ ...configured, rushVersion: '5.178.1' }); + fs.writeFileSync(rushJsonPath, original); + environment.RUSH_PREVIEW_VERSION = Rush.version; + const result: IResult = await invokeAsync(['build', '--to', 'b', '--verbose']); + expect(result.code).toBe(0); + expect(result.stderr).not.toMatch(/using in-process/i); + expect(result.stdout).toContain('built-a-one'); + expect(result.stdout).toContain('built-b-one'); + expect(fs.readFileSync(rushJsonPath, 'utf8')).toBe(original); + const status: IResult = await invokeAsync(['daemon', 'status']); + expect(status.code).toBe(0); + const initialPid: number = JSON.parse(status.stdout).pid; + expect((await invokeAsync(['daemon', 'restart'])).code).toBe(0); + expect(JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid).not.toBe(initialPid); + }), + 30000 + ); + + describe('after an initial native build', () => { + const argv: string[] = ['build', '--to', 'b', '--verbose']; + let previousPid: number; + beforeEach( + () => + runWithFixtureAsync(async ({ invokeAsync }) => { + expect((await invokeAsync(argv)).code).toBe(0); + previousPid = JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid; + }), + 30000 + ); + + it( + 'transparently replaces a hard-input generation and executes the original request exactly once', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + environment.RUSHD_TEST_RESTART_VALUE = 'new-process-environment'; + fs.writeFileSync(path.join(folder, 'a/input.txt'), 'two'); + const changed: IResult = await invokeAsync(argv); + expect(changed.code).toBe(0); + expect(changed.stderr).not.toMatch(/using in-process|restart/i); + expect(changed.stdout).toContain('built-a-two'); + const after = JSON.parse((await invokeAsync(['daemon', 'status'])).stdout); + expect(after.pid).not.toBe(previousPid); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\na:two\nb:one\n'); + expect((await invokeAsync(argv)).code).toBe(0); + expect(JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid).toBe(after.pid); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\na:two\nb:one\n'); + }), + 45000 + ); + }); +}); diff --git a/apps/rush-cli-client/src/test/nativeMutation.test.ts b/apps/rush-cli-client/src/test/nativeMutation.test.ts new file mode 100644 index 00000000000..062ff31a782 --- /dev/null +++ b/apps/rush-cli-client/src/test/nativeMutation.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { SuccessfulMutationFixture } from '@rushstack/rush-daemon/lib/test/SuccessfulMutationFixture'; +import { computeDaemonWorkspaceKey, resolveDaemonPaths } from '@rushstack/rush-daemon-transport'; + +interface IClientResult { + readonly exitCode: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +async function invokeAsync( + fixture: SuccessfulMutationFixture, + argv: ReadonlyArray +): Promise { + const child = spawn(process.execPath, [path.resolve(__dirname, '../../bin/rush-client'), ...argv], { + cwd: fixture.repoRoot, + env: { ...fixture.environment, RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + const [exitCode] = await once(child, 'close'); + return { exitCode: exitCode ?? undefined, stdout, stderr }; +} + +describe('native mutations through the standalone CLI and default daemon', () => { + it.each(['install', 'update'] as const)('runs %s without fallback or administrative replay', async (command) => { + const fixture: SuccessfulMutationFixture = await SuccessfulMutationFixture.createAsync(); + fixture.paths = resolveDaemonPaths( + { platform: process.platform, env: fixture.environment, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + computeDaemonWorkspaceKey({ canonicalRepoRoot: fs.realpathSync.native(fixture.repoRoot), rushVersion: Rush.version }) + ); + try { + expect(await fixture.runWorkerAsync('update')).toMatchObject({ exitCode: 0 }); + const started: IClientResult = await invokeAsync(fixture, ['daemon', 'start']); + expect(started.exitCode).toBe(0); + fixture.initialPid = JSON.parse(started.stdout).pid; + expect(fixture.initialPid).toEqual(expect.any(Number)); + expect(await invokeAsync(fixture, ['build', '--to', '@mutation/app', '--parallelism', '3'])) + .toMatchObject({ exitCode: 0 }); + expect(fixture.readAppOutput()).toBe('provider-one-installed:before'); + const lockfile: string = path.join(fixture.repoRoot, 'common/config/rush/pnpm-lock.yaml'); + const previousLockfile: string = fs.readFileSync(lockfile, 'utf8'); + if (command === 'update') fixture.selectSecondProvider(); + else fixture.removeInstalledAppDependency(); + + const mutation: IClientResult = await invokeAsync(fixture, [command, '--bypass-policy', '--offline']); + expect(mutation.exitCode).toBe(0); + expect(mutation.stderr).not.toMatch(/using in-process/i); + expect(mutation.stdout).toContain(`Found pnpm version ${fixture.pnpmVersion}`); + const successor = await fixture.waitForSuccessorAsync(); + expect(successor.pid).not.toBe(fixture.initialPid); + const status: IClientResult = await invokeAsync(fixture, ['daemon', 'status']); + expect(status.exitCode).toBe(0); + expect(JSON.parse(status.stdout).pid).toBe(successor.pid); + const currentLockfile: string = fs.readFileSync(lockfile, 'utf8'); + if (command === 'install') expect(currentLockfile).toBe(previousLockfile); + else expect(currentLockfile).not.toBe(previousLockfile); + + const rebuilt: IClientResult = await invokeAsync(fixture, ['rebuild', '--to', '@mutation/app', '--parallelism', '3']); + expect(rebuilt.exitCode).toBe(0); + expect(rebuilt.stderr).not.toMatch(/using in-process/i); + expect(fixture.readAppOutput()).toBe(`${command === 'install' ? 'provider-one' : 'provider-two'}-installed:before`); + expect(fs.readFileSync(lockfile, 'utf8')).toBe(currentLockfile); + expect(JSON.parse((await invokeAsync(fixture, ['daemon', 'status'])).stdout).pid).toBe(successor.pid); + } finally { + await fixture[Symbol.asyncDispose](); + } + }, 120_000); +}); diff --git a/apps/rush-cli-client/src/test/pipedInput.test.ts b/apps/rush-cli-client/src/test/pipedInput.test.ts new file mode 100644 index 00000000000..1698ef06830 --- /dev/null +++ b/apps/rush-cli-client/src/test/pipedInput.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { captureDaemonRequest, DaemonClient } from '@rushstack/rush-client-core'; +import { + DaemonRequestDispatchError, + RushDaemonHost, + type GlobalCommandExecutor, + type IDaemonRequestResolver +} from '@rushstack/rush-daemon'; + +const SCRIPT: string = + "const chunks=[];process.stdin.on('data',c=>{chunks.push(c);process.stdin.pause();" + + "setTimeout(()=>process.stdin.resume(),1);});" + + "process.stdin.on('end',()=>{process.stdout.write(Buffer.concat(chunks));" + + "process.stderr.write('pipe-ended');process.exitCode=7;});"; + +interface IPipedResult { + readonly code: number | undefined; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +describe('standalone client piped input', () => { + let folder: string; + let project: string; + let host: RushDaemonHost | undefined; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-cli-input-')); + project = path.join(folder, 'project'); + fs.mkdirSync(project); + fs.mkdirSync(path.join(folder, 'common/config/rush'), { recursive: true }); + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: '10.27.0', + daemon: { enabled: false, autoStart: false }, + projects: [{ packageName: 'sample', projectFolder: 'project' }], + projectFolderMinDepth: 1 + })); + fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ + name: 'sample', + version: '1.0.0', + scripts: { sample: `node -e "${SCRIPT}"` } + })); + }); + + afterEach(async () => { + const closingHost: RushDaemonHost | undefined = host; + host = undefined; + await closingHost?.closeAsync(); + fs.rmSync(folder, { recursive: true }); + }); + + async function startHostAsync(requestResolver: IDaemonRequestResolver): Promise { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version, + requestResolver + }); + } + + async function invokeAsync( + input: Buffer, + useClient: boolean = true, + admissionArgs: ReadonlyArray = [] + ): Promise { + const entry: string = useClient + ? path.resolve(__dirname, '../../bin/rushx-client') + : path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx'); + const child = spawn(process.execPath, [entry, 'sample', ...admissionArgs], { + cwd: project, + env: { + ...process.env, RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy', RUSH_QUIET_MODE: '1', + CI: 'false', TF_BUILD: 'false', GITHUB_ACTIONS: 'false' + }, + stdio: 'pipe' + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (bytes: Buffer) => stdout.push(bytes)); + child.stderr.on('data', (bytes: Buffer) => stderr.push(bytes)); + child.stdin.end(input); + const [code] = await once(child, 'close'); + return { + code: typeof code === 'number' ? code : undefined, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr) + }; + } + + it.each([Buffer.from([0, 3, 255, 13]), Buffer.alloc(0), Buffer.alloc(2 * 1024 * 1024, 3)])( + 'delivers pipe bytes and EOF through the real host without treating Ctrl+C bytes as signals', + async (input) => { + const executorAsync: GlobalCommandExecutor = async (context) => { + const child = context.spawnChild(process.execPath, ['-e', SCRIPT], { forwardInput: true }); + const [code] = await once(child, 'close'); + return { exitCode: typeof code === 'number' ? code : 1 }; + }; + await startHostAsync({ + resolveRequestAsync: async () => ({ + kind: 'global', + executor: executorAsync + }) + }); + const result: IPipedResult = await invokeAsync(input); + expect(result.code).toBe(7); + expect(result.stdout.equals(input)).toBe(true); + expect(result.stderr.toString()).toBe('pipe-ended'); + }, + 15000 + ); + + it.each([ + { args: ['--no-wait'], admission: { noWait: true }, reason: 'no-wait' }, + { args: ['--wait-timeout=0.01'], admission: { waitTimeoutMs: 10 }, reason: 'wait-timeout' } + ])('forwards $args without leaking queue flags to scripts', async ({ args, admission, reason }) => { + let started: () => void = () => {}; + let release: () => void = () => {}; + const running: Promise = new Promise((resolve) => { started = resolve; }); + const released: Promise = new Promise((resolve) => { release = resolve; }); + const runScriptAsync = jest.fn(async () => ({ exitCode: 0 })); + const holdAsync: GlobalCommandExecutor = async () => { + started(); + await released; + return { exitCode: 0 }; + }; + await startHostAsync({ + resolveRequestAsync: async ({ envelope }) => { + if (envelope.commandName === 'hold') return { kind: 'global', executor: holdAsync }; + expect(envelope.argv).toEqual(['sample']); + expect(envelope.admission).toEqual(admission); + return { kind: 'global', executor: runScriptAsync }; + } + }); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: host!.paths.socketPath }); + const holding = client.executeAsync({ + request: captureDaemonRequest({ + argv: ['hold'], commandName: 'hold', commandOrigin: 'custom', cwd: project, + environment: {}, terminal: { isTTY: false, supportsColor: false } + }) + }); + try { + await Promise.race([ + running, + holding.then(() => { throw new Error('The holding request finished before acquiring admission.'); }) + ]); + const result: IPipedResult = await invokeAsync(Buffer.alloc(0), true, args); + expect(result.code).toBe(1); + expect(result.stderr.toString()).toContain(`daemon admission failed (${reason})`); + expect(runScriptAsync).not.toHaveBeenCalled(); + } finally { + release(); + await holding; + await client.closeAsync(); + } + }, 15000); + + it('preserves every byte for native fallback when the host rejects before execution', async () => { + await startHostAsync({ + resolveRequestAsync: async () => { + throw new DaemonRequestDispatchError('unsupported', 'No command integration in this test.'); + } + }); + const input: Buffer = Buffer.from('untouched stdin\n'); + const native: IPipedResult = await invokeAsync(input, false); + const client: IPipedResult = await invokeAsync(input); + expect(client.code).toBe(native.code); + expect(client.stdout).toEqual(native.stdout); + expect(client.stderr.toString()).toContain('using in-process Rush'); + expect(client.stderr.toString()).toContain('pipe-ended'); + }, 15000); +}); diff --git a/apps/rush-cli-client/src/test/routing.test.ts b/apps/rush-cli-client/src/test/routing.test.ts new file mode 100644 index 00000000000..fdde8d785a6 --- /dev/null +++ b/apps/rush-cli-client/src/test/routing.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { selectClientRoute } from '../routing'; + +describe('opt-in routing', () => { + it.each([ + { argv: ['build'], enabled: false, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: {}, daemon: true }, + { argv: ['build', '--no-daemon'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { CI: '1' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { CI: '1', RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['build'], enabled: true, environment: { CI: 'false' }, daemon: true }, + { argv: ['build', '--reporter=json'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--output', 'build.log'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--log-level=debug'], enabled: true, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'json' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'legacy' }, daemon: true }, + { argv: ['install'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['update'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['install', '--no-daemon'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['install'], enabled: true, environment: { CI: '1' }, daemon: false }, + { argv: ['publish'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['daemon', 'status'], enabled: true, environment: {}, daemon: false }, + { argv: ['--help'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--help'], enabled: true, environment: {}, daemon: false } + ])('selects $daemon for $argv', ({ daemon, ...options }) => { + expect(selectClientRoute({ ...options, rushx: false }).daemon).toBe(daemon); + }); + + it('preserves script arguments after -- and permits scripts named like built-ins', () => { + expect( + selectClientRoute({ + argv: ['install', '--', '--no-daemon'], + enabled: true, + environment: {}, + rushx: true + }) + ).toEqual({ argv: ['install', '--', '--no-daemon'], commandName: 'install', daemon: true }); + }); + + it('uses native Rushx option boundaries instead of treating script flags as Rush options', () => { + const argv: string[] = ['-q', '-d', '--ignore-hooks', 'build', '--help', '--reporter=json', '--', '-h']; + expect(selectClientRoute({ argv, enabled: true, environment: {}, rushx: true })).toMatchObject({ + argv, commandName: 'build', daemon: true + }); + expect(selectClientRoute({ + argv: ['--unknown', 'build'], enabled: true, environment: {}, rushx: true + }).daemon).toBe(false); + }); +}); diff --git a/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts b/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts new file mode 100644 index 00000000000..cb29decaa69 --- /dev/null +++ b/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@microsoft/rush/lib/start', () => ({})); + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +import * as connectionOptions from '../daemonConnectionOptions'; +import { launchClientAsync } from '../launchClient'; + +it('uses the native version selector when no compatible foreign daemon launcher exists', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-version-fallback-')); + const originalArgv: string[] = process.argv; + const originalEnvironment: NodeJS.ProcessEnv = process.env; + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: '5.178.1', pnpmVersion: '10.27.0', projects: [] + })); + const selection = jest.spyOn(connectionOptions, 'getDaemonConnectionOptionsAsync').mockRejectedValue( + new DaemonLauncherUnavailableError('5.178.1', 'No compatible request-launch APIs.') + ); + const output = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + jest.spyOn(process, 'cwd').mockReturnValue(folder); + process.argv = [process.execPath, 'rush-client', 'build', '--to', 'project']; + process.env = { ...originalEnvironment, CI: 'false', RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy' }; + delete process.env.RUSH_PREVIEW_VERSION; + try { + await launchClientAsync(false); + expect(process.argv[1]).toBe(path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rush')); + expect(selection).toHaveBeenCalledWith(folder, '5.178.1', expect.objectContaining({ RUSH_DAEMON: '1' }), true); + expect(Object.isFrozen(selection.mock.calls[0][2])).toBe(true); + expect(process.argv.slice(2)).toEqual(['build', '--to', 'project']); + expect(process.env.RUSH_DAEMON).toBeUndefined(); + expect(output).toHaveBeenCalledWith(expect.stringContaining('Using in-process Rush')); + } finally { + process.argv = originalArgv; + process.env = originalEnvironment; + jest.restoreAllMocks(); + fs.rmSync(folder, { recursive: true }); + } +}); diff --git a/apps/rush-cli-client/src/writeStreamAsync.ts b/apps/rush-cli-client/src/writeStreamAsync.ts new file mode 100644 index 00000000000..94f7cd3eeb9 --- /dev/null +++ b/apps/rush-cli-client/src/writeStreamAsync.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Writable } from 'node:stream'; + +export function writeStreamAsync(destination: Writable, bytes: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error); + destination.once('error', onError); + destination.write(bytes, (error?: Error | null) => { + if (error) { + reject(error); + } else { + destination.removeListener('error', onError); + resolve(); + } + }); + }); +} diff --git a/apps/rush-cli-client/tsconfig.json b/apps/rush-cli-client/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/apps/rush-cli-client/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/apps/rush/README.md b/apps/rush/README.md index 336d835ef07..21e3ed293d1 100644 --- a/apps/rush/README.md +++ b/apps/rush/README.md @@ -64,6 +64,10 @@ _(If you don't have a GitHub account set up, you can use `rush install --bypass- For more details and support resources, please visit: https://rushjs.io +The repository also includes an +[experimental Rush reporter opt-in and demo guide](../../docs/rush/reporter.md). Rush 5 retains legacy +terminal output unless a reporter is explicitly enabled. + ## Links - [CHANGELOG.md]( diff --git a/apps/rush/UPGRADING.md b/apps/rush/UPGRADING.md index eccb56d59c9..40da1e28303 100644 --- a/apps/rush/UPGRADING.md +++ b/apps/rush/UPGRADING.md @@ -45,6 +45,18 @@ approaches: Dynamic registry and proxy URLs must also move out of the project `.npmrc` and into trusted user, global, CLI, or environment configuration. +### Experimental Rush reporter opt-in + +Rush 5 keeps the existing terminal output by default. Maintainers can evaluate the new reporter path for one +invocation with `--reporter=` or for a repository with `"useRushReporter": true` in +`common/config/rush/experiments.json`. + +Use `RUSH_REPORTER=legacy` for an immediate reporter-only rollback. Environment-based automatic AI selection +and the planned Rush 6 default change are not enabled yet. + +See the [experimental Rush reporter guide](../../docs/rush/reporter.md) for the control precedence, stdout and +stderr contracts, full-log and privacy behavior, cross-version prerequisite, and reproducible demo. + ### Rush 5.135.0 This release of Rush deprecates the `rush-project.json`'s `operationSettings.sharding.shardOperationSettings` diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..9bf40d8980a --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; + +/** + * The cross-version launch contract owned by the Rush frontend. + * + * @remarks + * Reporter selection remains in `@microsoft/rush`. The selected `rush-lib` + * receives only the typed producer sink in addition to its existing launch + * options, so an older engine can safely ignore the new property. + */ +export interface IRushFrontendLaunchOptions extends ILaunchOptions { + readonly reporter: IRushSessionReporterOptions; + readonly reporterCloseAsync: () => Promise; + readonly reporterEnabled: boolean; + readonly reporterStdoutIsMachineReadable?: boolean; + readonly reporterStdoutIsReserved?: boolean; + readonly reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; +} diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 0cc4436b964..44850a1e5eb 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -3,16 +3,24 @@ import * as path from 'node:path'; -import { JsonFile } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; import { RushConfiguration } from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { isSupportedReporterName, type ReporterName } from '@rushstack/rush-reporter'; + +import { getRushPreviewVersion } from './RushPreviewVersion'; interface IMinimalRushConfigurationJson { rushMinimumVersion: string; rushVersion?: string; } +interface IMinimalExperimentsConfigurationJson { + useRushReporter?: boolean; +} + /** * Represents a minimal subset of the rush.json configuration file. It provides the information necessary to * decide which version of Rush should be installed/used. @@ -20,6 +28,7 @@ interface IMinimalRushConfigurationJson { export class MinimalRushConfiguration { private _rushVersion: string; private _commonRushConfigFolder: string; + private _useRushReporter: boolean; private constructor(minimalRushConfigurationJson: IMinimalRushConfigurationJson, rushJsonFilename: string) { this._rushVersion = @@ -30,17 +39,56 @@ export class MinimalRushConfiguration { 'config', 'rush' ); + + const experimentsJsonFilename: string = path.join( + this._commonRushConfigFolder, + RushConstants.experimentsFilename + ); + const experimentsConfiguration: IMinimalExperimentsConfigurationJson | undefined = + _loadExperimentsConfigurationJson(experimentsJsonFilename); + if ( + experimentsConfiguration?.useRushReporter !== undefined && + typeof experimentsConfiguration.useRushReporter !== 'boolean' + ) { + throw new Error(`The "useRushReporter" setting in "${experimentsJsonFilename}" must be true or false.`); + } + this._useRushReporter = experimentsConfiguration?.useRushReporter === true; } - public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { + public static loadFromDefaultLocation( + writeLine: (message: string) => void = (message) => console.log(message) + ): MinimalRushConfiguration | undefined { + const showVerbose: boolean = !RushCommandLineParser.shouldRestrictConsoleOutput(); const rushJsonLocation: string | undefined = RushConfiguration.tryFindRushJsonLocation({ - showVerbose: !RushCommandLineParser.shouldRestrictConsoleOutput() + showVerbose: false }); if (rushJsonLocation) { const minimalRushConfigurationJson: IMinimalRushConfigurationJson | undefined = _loadConfigurationJson(rushJsonLocation); if (minimalRushConfigurationJson) { - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + const configuration: MinimalRushConfiguration = new MinimalRushConfiguration( + minimalRushConfigurationJson, + rushJsonLocation + ); + const explicitReporter: ReporterName | undefined = _getExplicitReporter(process.argv.slice(2)); + const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; + const effectiveRushVersion: string = getRushPreviewVersion() ?? configuration.rushVersion; + const legacyFallbackRequested: boolean = + explicitReporter === 'legacy' || + process.env.RUSH_REPORTER?.trim().toLowerCase() === 'legacy' || + _hasHelpControl(process.argv.slice(2)) || + (effectiveRushVersion !== currentPackageVersion && explicitReporter === undefined); + if ( + showVerbose && + (legacyFallbackRequested || + (!configuration.useRushReporter && + (explicitReporter === undefined || explicitReporter === 'legacy'))) + ) { + // Preserve the legacy discovery message exactly when the reporter path is not taking ownership. + writeLine('Found configuration in ' + rushJsonLocation); + writeLine(''); + } + return configuration; } return undefined; } else { @@ -68,6 +116,60 @@ export class MinimalRushConfiguration { public get commonRushConfigFolder(): string { return this._commonRushConfigFolder; } + + /** + * Whether the repository explicitly opted in to the experimental Rush reporter frontend. + */ + public get useRushReporter(): boolean { + return this._useRushReporter; + } + + /** + * The repository's common temp folder, used for invocation-scoped reporter logs. + */ + public get commonTempFolder(): string { + return ( + EnvironmentConfiguration._getRushTempFolderOverride(process.env) ?? + path.resolve(this._commonRushConfigFolder, '..', '..', 'temp') + ); + } +} + +function _getExplicitReporter(argv: readonly string[]): ReporterName | undefined { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + let value: string | undefined; + if (argument === '--reporter') { + const nextArgument: string | undefined = argv[index + 1]; + if (!nextArgument || nextArgument.startsWith('-')) { + continue; + } + value = nextArgument; + index++; + } else if (argument.startsWith('--reporter=')) { + value = argument.slice('--reporter='.length); + } + if (value !== undefined) { + const normalizedValue: string = value.trim().toLowerCase(); + return isSupportedReporterName(normalizedValue) ? normalizedValue : undefined; + } + } + return undefined; +} + +function _hasHelpControl(argv: readonly string[]): boolean { + for (const argument of argv) { + if (argument === '--') { + return false; + } + if (argument === '--help' || argument === '-h') { + return true; + } + } + return false; } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { @@ -77,3 +179,16 @@ function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigura return undefined; } } + +function _loadExperimentsConfigurationJson( + experimentsJsonFilename: string +): IMinimalExperimentsConfigurationJson | undefined { + try { + return JsonFile.load(experimentsJsonFilename); + } catch (e) { + if (FileSystem.isNotExistError(e)) { + return undefined; + } + throw e; + } +} diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..2db34265938 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -2,9 +2,17 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; -import type { ILaunchOptions } from '@microsoft/rush-lib/lib/index'; -import { Colorize } from '@rushstack/terminal'; +import { + LegacyFallbackSink, + OldEngineOutputAdapter, + REPORTER_PROTOCOL_VERSION, + resolveReporterCompatibility, + type IReporterCompatibilityDecision +} from '@rushstack/rush-reporter'; + +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; @@ -28,7 +36,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; @@ -38,35 +46,172 @@ export class RushCommandSelector { } const commandName: CommandName = _getCommandName(); - - if (commandName === 'rush-pnpm') { - if (!Rush.launchRushPnpm) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rush-pnpm" command` - ); + const engineProtocolMajor: number | undefined = ( + Rush as typeof Rush & { readonly _reporterProtocolMajor?: number } + )._reporterProtocolMajor; + const compatibility: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: REPORTER_PROTOCOL_VERSION.major, hasManager: true }, + { + supportsStructuredSink: engineProtocolMajor !== undefined, + protocolMajor: engineProtocolMajor } - Rush.launchRushPnpm(launcherVersion, { - isManaged: options.isManaged, - alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError - }); - } else if (commandName === 'rushx') { - if (!Rush.launchRushX) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rushx" command` + ); + let effectiveOptions: IRushFrontendLaunchOptions = options; + let restoreEngineOutput: (() => void) | undefined; + if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { + if (options.reporterSelectionReason === 'explicit --reporter') { + throw new Error( + `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + + `frontend supports major ${REPORTER_PROTOCOL_VERSION.major}. Update global Rush or use ` + + '--reporter=legacy.' ); } - Rush.launchRushX(launcherVersion, options); - } else { - Rush.launch(launcherVersion, options); + effectiveOptions = { + ...options, + reporter: { + ...options.reporter, + eventSink: new LegacyFallbackSink() + }, + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }; + } else if (options.reporterEnabled) { + restoreEngineOutput = _observeEngineOutput(options, Rush.version); + effectiveOptions = { + ...options, + reporterCloseAsync: async () => { + restoreEngineOutput?.(); + await options.reporterCloseAsync(); + } + }; + } + + try { + if (commandName === 'rush-pnpm') { + if (!Rush.launchRushPnpm) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rush-pnpm" command` + ); + } + Rush.launchRushPnpm(launcherVersion, { + isManaged: options.isManaged, + alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError + }); + } else if (commandName === 'rushx') { + if (!Rush.launchRushX) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rushx" command` + ); + } + Rush.launchRushX(launcherVersion, effectiveOptions); + } else { + Rush.launch(launcherVersion, effectiveOptions); + } + } catch (error) { + restoreEngineOutput?.(); + throw error; } } } +function _observeEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): () => void { + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: options.reporter.eventSink, + sessionId: options.reporter.sessionId, + source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } + }); + const restoreStdout: () => void = _observeStream( + process.stdout, + 'stdout', + adapter, + process.stdout.write.bind(process.stdout), + (options.reporterStdoutIsReserved ?? options.reporterStdoutIsMachineReadable) !== true + ); + const restoreStderr: () => void = _observeStream( + process.stderr, + 'stderr', + adapter, + process.stderr.write.bind(process.stderr), + true + ); + let restored: boolean = false; + const restore: () => void = () => { + if (restored) { + return; + } + restored = true; + process.removeListener('beforeExit', restore); + process.removeListener('exit', restore); + restoreStdout(); + restoreStderr(); + }; + process.once('beforeExit', restore); + process.once('exit', restore); + return restore; +} + +function _observeStream( + stream: NodeJS.WriteStream, + streamName: 'stdout' | 'stderr', + adapter: OldEngineOutputAdapter, + legacyWrite: typeof process.stdout.write, + renderLive: boolean +): () => void { + const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); + const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + if (markedStream[marker]) { + return () => {}; + } + markedStream[marker] = true; + + let captureInProgress: boolean = false; + const decoder: StringDecoder = new StringDecoder('utf8'); + const originalWrite: typeof stream.write = stream.write; + stream.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ): boolean => { + const text: string = + typeof chunk === 'string' + ? chunk + : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + if (text && !captureInProgress) { + captureInProgress = true; + try { + adapter.capture(streamName, text, renderLive); + } finally { + captureInProgress = false; + } + } + if (!renderLive) { + const writeCallback: ((error?: Error | null) => void) | undefined = + typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + if (writeCallback) { + process.nextTick(writeCallback); + } + return true; + } + if (typeof encodingOrCallback === 'function') { + return legacyWrite(chunk, encodingOrCallback); + } + return legacyWrite(chunk, encodingOrCallback, callback); + }) as typeof stream.write; + return () => { + const remaining: string = decoder.end(); + if (remaining) { + adapter.capture(streamName, remaining, renderLive); + } + stream.write = originalWrite; + delete markedStream[marker]; + }; +} + function _failWithError(message: string): never { - console.log(Colorize.red(message)); - return process.exit(1); + throw new Error(message); } function _getCommandName(): CommandName { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts new file mode 100644 index 00000000000..81c2bf6501b --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; + +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import { + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, + REPORTER_PROTOCOL_VERSION, + resolveColorEnabled +} from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IRushReporterHostOptions, + type IInitializedRushReporterHost +} from './RushReporterHost'; +import { RushCommandSelector } from './RushCommandSelector'; +import { RushVersionSelector } from './RushVersionSelector'; +import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + +export interface IRushFrontendOptions { + readonly currentPackageVersion: string; + readonly rushVersionToLoad: string | undefined; + readonly configuration: MinimalRushConfiguration | undefined; + readonly launchOptions: ILaunchOptions; + readonly currentRushLib: typeof import('@microsoft/rush-lib'); + readonly initializeReporterHostAsync?: ( + options: IRushReporterHostOptions + ) => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => void | Promise; + readonly createSessionId?: () => string; + readonly processLifecycle?: IRushFrontendProcessLifecycle; +} + +type RushTerminationSignal = 'SIGINT' | 'SIGTERM'; + +export interface IRushFrontendProcessLifecycle { + registerBeforeExit(listener: () => void): () => void; + registerSignal(signal: RushTerminationSignal, listener: () => void): () => void; + terminate(signal: RushTerminationSignal): void; + setExitCode(exitCode: number): void; + reportCloseError(error: Error): void; +} + +class RushFrontendReporterLifecycle { + private readonly _reporterHost: IInitializedRushReporterHost; + private readonly _processLifecycle: IRushFrontendProcessLifecycle; + private _disposeBeforeExit: (() => void) | undefined; + private readonly _disposeSignalHandlers: Array<() => void> = []; + private _closePromise: Promise | undefined; + + public constructor( + reporterHost: IInitializedRushReporterHost, + processLifecycle: IRushFrontendProcessLifecycle + ) { + this._reporterHost = reporterHost; + this._processLifecycle = processLifecycle; + } + + public start(): void { + this._disposeBeforeExit = this._processLifecycle.registerBeforeExit(() => { + void this.closeAsync().catch((error: Error) => { + this._processLifecycle.reportCloseError(error); + this._processLifecycle.setExitCode(1); + }); + }); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + this._disposeSignalHandlers.push( + this._processLifecycle.registerSignal(signal, () => { + this._disposeSignals(); + void this._closeForSignalAsync(signal); + }) + ); + } + } + + public closeAsync(timeoutMs?: number): Promise { + if (!this._closePromise) { + this._closePromise = Promise.resolve() + .then(() => this._reporterHost.closeAsync(timeoutMs)) + .finally(() => this._dispose()); + } + return this._closePromise; + } + + private _dispose(): void { + this._disposeBeforeExit?.(); + this._disposeBeforeExit = undefined; + this._disposeSignals(); + } + + private _disposeSignals(): void { + for (const dispose of this._disposeSignalHandlers.splice(0)) { + dispose(); + } + } + + private async _closeForSignalAsync(signal: RushTerminationSignal): Promise { + const closeResult: Promise = this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS).then( + () => undefined, + (error: Error) => error + ); + let timeout: ReturnType | undefined; + const deadline: Promise<'deadline'> = new Promise((resolve: (value: 'deadline') => void) => { + timeout = setTimeout(() => resolve('deadline'), DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS); + }); + + const result: Error | 'deadline' | undefined = await Promise.race([closeResult, deadline]); + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (result === 'deadline') { + this._processLifecycle.reportCloseError( + new Error(`Reporter close exceeded the ${DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS}ms signal deadline.`) + ); + } else if (result) { + this._processLifecycle.reportCloseError(result); + } + this._dispose(); + this._processLifecycle.terminate(signal); + } +} + +export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, + processLifecycle = createProcessLifecycle() + } = options; + + const engineArgv: string[] = stripReporterValueControls(process.argv.slice(2)); + const actionName: string | undefined = engineArgv.find((argument: string) => !argument.startsWith('-')); + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter, + forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, + selectedRushVersion: rushVersionToLoad, + commonTempFolder: actionName === 'purge' ? undefined : configuration?.commonTempFolder, + actionName + }); + const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled + ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) + : undefined; + reporterLifecycle?.start(); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { + process.argv = stripReporterValueControls( + process.argv, + new Set(reporterHost.selection.reporterValueFlagsToStrip) + ); + delete process.env.RUSH_REPORTER; + delete process.env.RUSH_LOG_LEVEL; + } + const reporterCloseAsync: () => Promise = () => + reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); + const requestId: string = sessionId; + const stdoutColumns: number | undefined = process.stdout.columns; + const terminalWidth: number = + stdoutColumns !== undefined && Number.isSafeInteger(stdoutColumns) && stdoutColumns > 0 + ? stdoutColumns + : 80; + if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { + reporterHost.sink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId, + source: { packageName: '@microsoft/rush', packageVersion: currentPackageVersion }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: reporterHost.logArtifact.path, + format: 'plaintext', + complete: false + } + }); + } + const reporterLaunchOptions: IRushFrontendLaunchOptions = { + ...launchOptions, + reporter: { + eventSink: reporterHost.sink, + sessionId, + operationStreamEnabled: reporterHost.selection.enabled, + childProcessReporter: reporterHost.selection.enabled + ? { + requestId, + context: { + reporter: reporterHost.selection.reporter, + logLevel: reporterHost.selection.logLevel, + color: + reporterHost.selection.reporter === 'default' + ? resolveColorEnabled(process.env, process.stdout.isTTY === true) + : false, + terminalWidth + }, + ingestForeignEnvelope: (envelope) => reporterHost.host.manager.ingestForeignEnvelope(envelope) + } + : undefined + }, + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterStdoutIsMachineReadable: + reporterHost.selection.reporter === 'ai' || reporterHost.selection.reporter === 'json', + reporterStdoutIsReserved: + !reporterHost.selection.commandJson && + (reporterHost.selection.reporter === 'ai' || + reporterHost.selection.reporter === 'json' || + reporterHost.selection.reporter === 'file'), + reporterSelectionReason: reporterHost.selection.reason + }; + + try { + if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { + const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); + await versionSelector.ensureRushVersionInstalledAsync( + rushVersionToLoad, + configuration, + reporterLaunchOptions + ); + } else { + await executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + } + } catch (error) { + try { + await reporterCloseAsync(); + } catch (closeError) { + processLifecycle.reportCloseError(closeError as Error); + processLifecycle.setExitCode(1); + } + throw error; + } +} + +function createProcessLifecycle(): IRushFrontendProcessLifecycle { + return { + registerBeforeExit: (listener: () => void) => { + process.once('beforeExit', listener); + return () => process.off('beforeExit', listener); + }, + registerSignal: (signal: RushTerminationSignal, listener: () => void) => { + process.once(signal, listener); + return () => process.off(signal, listener); + }, + terminate: (signal: RushTerminationSignal) => { + process.kill(process.pid, signal); + }, + setExitCode: (exitCode: number) => { + process.exitCode = exitCode; + }, + reportCloseError: (error: Error) => { + process.stderr.write(`[reporter] Unable to finalize reporters: ${error.message}\n`); + } + }; +} diff --git a/apps/rush/src/RushPreviewVersion.ts b/apps/rush/src/RushPreviewVersion.ts new file mode 100644 index 00000000000..87bae4dc561 --- /dev/null +++ b/apps/rush/src/RushPreviewVersion.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { EnvironmentVariableNames } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; + +export function getRushPreviewVersion( + env: Record = process.env +): string | undefined { + return env[EnvironmentVariableNames.RUSH_PREVIEW_VERSION] || undefined; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts new file mode 100644 index 00000000000..0d21b13cbb1 --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,1031 @@ +// 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 { + AiReporter, + DefaultInteractiveReporter, + FileReporter, + JsonReporter, + PlaintextReporter, + ReporterHost, + isCiDetected, + isLegacyEmergencyFallbackRequested, + isSupportedLogLevel, + isSupportedReporterName, + parseOutputControl, + separateJsonControls, + shouldRenderAtLogLevel, + type IReporter, + type IReporterContext, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSink, + type IFileReporterArtifact, + type IReporterOutputTarget, + type ReporterEventType, + type ReporterLogLevel, + type ReporterName, + type ReporterManager, + type IBootstrapReplayResult, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR +} from '@rushstack/rush-reporter'; + +export interface IRushReporterOutputStream { + readonly isTTY?: boolean; + readonly columns?: number; + write(text: string): unknown; +} + +export interface IRushReporterHostOptions { + readonly argv?: readonly string[]; + readonly env?: Record; + readonly cwd?: string; + readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; + readonly commonTempFolder?: string; + readonly actionName?: string; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; + readonly forceLegacy?: boolean; + readonly selectedRushVersion?: string; + readonly manager?: ReporterManager; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reporterValueFlagsToStrip: readonly string[]; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; +} + +export interface IInitializedRushReporterHost { + readonly host: ReporterHost; + readonly sink: IReporterEventSink; + readonly selection: IRushReporterSelection; + readonly logArtifact: IFileReporterArtifact | undefined; + closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; +} + +const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); +const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; +const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; +const REPORTER_OUTPUT_VALUE_FLAGS: readonly string[] = ['--output', '--log-level']; +const GROUPED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ + 'operationRegistered', + 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted', + 'externalOutput' +]); + +interface IParsedReporterControls { + readonly reporters: readonly string[]; + readonly logLevels: readonly string[]; + readonly outputs: readonly string[]; + readonly quiet: boolean; + readonly verbose: boolean; + readonly debug: boolean; +} + +class LogLevelReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + private readonly _logLevel: ReporterLogLevel; + private readonly _preserveOperationStream: boolean; + + public constructor( + reporter: IReporter, + logLevel: ReporterLogLevel, + preserveOperationStream: boolean = false + ) { + this._reporter = reporter; + this._logLevel = logLevel; + this._preserveOperationStream = preserveOperationStream; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if ( + shouldRenderAtLogLevel(this._logLevel, event) || + event.type === 'artifactAvailable' || + (this._preserveOperationStream && + GROUPED_OPERATION_EVENT_TYPES.has(event.type) && + !(this._logLevel === 'quiet' && event.type === 'externalOutput')) + ) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + +class VisibleBootstrapOutputFilterReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + + public constructor(reporter: IReporter) { + this._reporter = reporter; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + const payload: { readonly wasRendered?: unknown } | undefined = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as { readonly wasRendered?: unknown }) + : undefined; + if (event.type === 'externalOutput' && payload?.wasRendered === true) { + return; + } + this._reporter.report(event); + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + +class ExplicitOutputReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: JsonReporter; + private readonly _filteredReporter: LogLevelReporter; + private readonly _outputPath: string; + private _fileDescriptor: number | undefined; + + public constructor(reporterName: string, outputPath: string, logLevel: ReporterLogLevel) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._fileDescriptor === undefined) { + throw new Error(`Reporter output ${JSON.stringify(this._outputPath)} is not initialized.`); + } + fs.writeSync(this._fileDescriptor, text); + } + }); + this._filteredReporter = new LogLevelReporter(this._reporter, logLevel); + } + + public async initializeAsync(context: IReporterContext): Promise { + await fs.promises.mkdir(path.dirname(this._outputPath), { recursive: true }); + this._fileDescriptor = fs.openSync(this._outputPath, 'w', 0o600); + await this._filteredReporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + this._filteredReporter.report(event); + } + + public async flushAsync(): Promise { + await this._filteredReporter.flushAsync(); + if (this._fileDescriptor !== undefined) { + fs.fsyncSync(this._fileDescriptor); + } + } + + public async closeAsync(): Promise { + try { + await this._filteredReporter.closeAsync(); + } finally { + if (this._fileDescriptor !== undefined) { + fs.closeSync(this._fileDescriptor); + this._fileDescriptor = undefined; + } + } + } +} + +class FilePathReporter implements IReporter { + public readonly name: string = 'file-path'; + + private readonly _write: (text: string) => unknown; + private _path: string | undefined; + private _written: boolean = false; + + public constructor(write: (text: string) => unknown) { + this._write = write; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string } = event.payload as { + role?: string; + path?: string; + }; + if (payload.role === 'log') { + this._path = payload.path; + } + } else if ((event.type === 'commandResult' || event.type === 'sessionCompleted') && this._path) { + this._writePathOnce(); + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + this._writePathOnce(); + } + + private _writePathOnce(): void { + if (!this._written && this._path) { + this._written = true; + this._write(`Rush full log: ${this._path}\n`); + } + } +} + +class ArtifactCompletionReporterSink implements IReporterEventSink { + private readonly _host: ReporterHost; + private readonly _fullDetailReporter: FileReporter; + private _lastComplete: boolean | undefined; + private _artifactContext: IReporterEmitEventInput | undefined; + + public constructor(host: ReporterHost, fullDetailReporter: FileReporter) { + this._host = host; + this._fullDetailReporter = fullDetailReporter; + } + + public emit(event: IReporterEmitEventInput): string { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string; complete?: boolean } = event.payload as { + role?: string; + path?: string; + complete?: boolean; + }; + if (payload.role === 'log' && typeof payload.complete === 'boolean') { + this._lastComplete = payload.complete; + this._artifactContext = event; + } + } + return this._host.manager.emit(event); + } + + public publishIfChanged( + context: IReporterEmitEventInput | undefined = this._artifactContext + ): void { + if (!context) { + return; + } + const artifact: IFileReporterArtifact = this._fullDetailReporter.getArtifact(); + if (!artifact.path || artifact.complete === this._lastComplete) { + return; + } + const complete: boolean = artifact.complete; + const payload: Readonly<{ + role: 'log'; + path: string; + format: 'plaintext'; + complete: boolean; + }> = Object.freeze({ + role: 'log' as const, + path: artifact.path, + format: 'plaintext' as const, + complete + }); + this._host.manager.emit({ + protocolVersion: context.protocolVersion, + sessionId: context.sessionId, + source: context.source, + scope: context.scope, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload + }); + this._lastComplete = complete; + } +} + +function readValue( + argv: readonly string[], + index: number, + flag: string +): { readonly value: string; readonly consumedNext: boolean } | undefined { + const argument: string = argv[index]; + const prefix: string = `${flag}=`; + if (argument.startsWith(prefix)) { + const value: string = argument.slice(prefix.length); + if (!value) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: false }; + } + if (argument !== flag) { + return undefined; + } + + const value: string | undefined = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: true }; +} + +export function stripReporterValueControls( + argv: readonly string[], + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS +): string[] { + const result: string[] = []; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + result.push(...argv.slice(index)); + break; + } + const equalsIndex: number = argument.indexOf('='); + const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); + if (!valueFlagsToStrip.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + index++; + } + } + return result; +} + +function parseReporterControls( + argv: readonly string[], + includeOutputAndLogLevelControls: boolean, + tolerateMissingReporterValue: boolean = false +): IParsedReporterControls { + const reporters: string[] = []; + const logLevels: string[] = []; + const outputs: string[] = []; + let quiet: boolean = false; + let verbose: boolean = false; + let debug: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + if ( + tolerateMissingReporterValue && + argument === '--reporter' && + (!argv[index + 1] || argv[index + 1].startsWith('-')) + ) { + continue; + } + const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.consumedNext ? 1 : 0; + continue; + } + if (includeOutputAndLogLevelControls) { + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--log-level' + ); + if (logLevel) { + logLevels.push(logLevel.value); + index += logLevel.consumedNext ? 1 : 0; + continue; + } + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--output' + ); + if (output) { + outputs.push(output.value); + index += output.consumedNext ? 1 : 0; + continue; + } + } + + quiet ||= argument === '--quiet' || argument === '-q'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + return { reporters, logLevels, outputs, quiet, verbose, debug }; +} + +function validateReporterControlMultiplicity( + controls: IParsedReporterControls, + includeOutputAndLogLevelControls: boolean +): void { + if (controls.reporters.length > 1) { + throw new Error('--reporter may be specified only once.'); + } + if (includeOutputAndLogLevelControls && controls.logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } +} + +function hasReporterOutputControl(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + const prefix: string = '--output='; + const value: string | undefined = argument.startsWith(prefix) + ? argument.slice(prefix.length) + : argument === '--output' && argv[index + 1] && !argv[index + 1].startsWith('-') + ? argv[index + 1] + : undefined; + if (value && /^(?:file|json):\/\//.test(value)) { + return true; + } + } + return false; +} + +function hasReporterLogLevelControl(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + if (argument.startsWith('--log-level=') && argument.length > '--log-level='.length) { + return true; + } + if (argument === '--log-level' && argv[index + 1] !== undefined && !argv[index + 1].startsWith('-')) { + return true; + } + } + return false; +} + +function hasHelpControl(argv: readonly string[]): boolean { + for (const argument of argv) { + if (argument === '--') { + return false; + } + if (argument === '--help' || argument === '-h') { + return true; + } + } + return false; +} + +function resolveLogLevel( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean, + useLegacyAliasPrecedence: boolean = false +): ReporterLogLevel { + const requestedLevels: ReporterLogLevel[] = []; + const explicitLogLevel: string | undefined = controls.logLevels[0]; + if (explicitLogLevel !== undefined) { + if (!isSupportedLogLevel(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + requestedLevels.push(explicitLogLevel); + } + if (useLegacyAliasPrecedence && explicitLogLevel === undefined) { + if (controls.debug) { + return 'debug'; + } + if (controls.verbose) { + return 'verbose'; + } + if (controls.quiet) { + return 'quiet'; + } + } + if (controls.quiet) { + requestedLevels.push('quiet'); + } + if (controls.verbose) { + requestedLevels.push('verbose'); + } + if (controls.debug) { + requestedLevels.push('debug'); + } + + const distinctLevels: Set = new Set(requestedLevels); + if (distinctLevels.size > 1) { + throw new Error( + `Contradictory reporter verbosity controls were specified: ${[...distinctLevels].sort().join(', ')}. ` + + 'Specify only one of --log-level, --quiet, --verbose, or --debug.' + ); + } + if (requestedLevels.length > 0) { + return requestedLevels[0]; + } + + const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + const environmentQuiet: boolean = + includeEnvironment && (env.RUSH_QUIET_MODE === '1' || env.RUSH_QUIET_MODE?.toLowerCase() === 'true'); + if (environmentQuiet && environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (normalizedLogLevel !== 'quiet') { + throw new Error( + 'RUSH_QUIET_MODE contradicts RUSH_LOG_LEVEL. Remove one of these environment controls.' + ); + } + } + if (environmentQuiet) { + return 'quiet'; + } + if (environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (!isSupportedLogLevel(normalizedLogLevel)) { + throw new Error( + `Unsupported RUSH_LOG_LEVEL value ${JSON.stringify(environmentLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return normalizedLogLevel; + } + + return 'normal'; +} + +function resolveOutputs(outputValues: readonly string[], cwd: string): readonly IReporterOutputTarget[] { + return outputValues.map((value: string) => { + const output: IReporterOutputTarget = parseOutputControl(value); + if (output.reporter !== 'file' && output.reporter !== 'json') { + throw new Error( + `Unsupported --output reporter ${JSON.stringify(output.reporter)}. ` + + 'This rollout stage supports file:// and json:// output targets.' + ); + } + if (!output.target) { + throw new Error(`The --output target must not be empty: ${JSON.stringify(value)}.`); + } + for (const parameterName of Object.keys(output.params)) { + if (parameterName !== 'logLevel') { + throw new Error( + `Unsupported --output query parameter ${JSON.stringify(parameterName)}. ` + + 'The only supported query parameter is logLevel.' + ); + } + } + const outputLogLevel: string | undefined = output.params.logLevel; + if (outputLogLevel !== undefined && !isSupportedLogLevel(outputLogLevel)) { + throw new Error( + `Unsupported --output logLevel ${JSON.stringify(outputLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return { + ...output, + target: path.resolve(cwd, output.target) + }; + }); +} + +export function resolveRushReporterSelection(options: IRushReporterHostOptions = {}): IRushReporterSelection { + const argv: readonly string[] = options.argv ?? process.argv.slice(2); + const env: Record = options.env ?? process.env; + const commandName: 'rush' | 'rush-pnpm' | 'rushx' = options.commandName ?? getCommandName(); + if (commandName !== 'rush') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: separateJsonControls(argv).commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); + const reporterOwnershipEstablished: boolean = + options.repositoryOptIn === true || + hasReporterOutputControl(argv) || + reporterProbe.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + const selectionControls: IParsedReporterControls = reporterOwnershipEstablished + ? parseReporterControls(argv, false) + : reporterProbe; + if (reporterOwnershipEstablished) { + validateReporterControlMultiplicity(selectionControls, false); + } + const reporterValue: string | undefined = reporterOwnershipEstablished + ? selectionControls.reporters[0] + : undefined; + if (reporterValue !== undefined && !isSupportedReporterName(reporterValue)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(reporterValue)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + const requestedReporter: ReporterName | undefined = reporterValue; + + if (isLegacyEmergencyFallbackRequested(env)) { + const reporterValueFlagsToStrip: readonly string[] = + requestedReporter === 'legacy' + ? REPORTER_SELECTION_FLAG + : requestedReporter === undefined + ? [] + : ALL_REPORTER_VALUE_FLAGS; + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip, + reason: 'RUSH_REPORTER=legacy' + }; + } + + if (options.forceLegacy) { + if (requestedReporter !== undefined && requestedReporter !== 'legacy') { + throw new Error( + `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + + `cannot safely use --reporter=${requestedReporter} because this frontend cannot verify its ` + + 'reporter close contract. Remove the explicit reporter request or use the Rush version bundled ' + + 'with this frontend.' + ); + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + + if (hasHelpControl(argv)) { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + if (requestedReporter === undefined) { + const environmentReporter: string | undefined = env.RUSH_REPORTER; + if (environmentReporter?.trim()) { + throw new Error( + `RUSH_REPORTER=${JSON.stringify(environmentReporter)} cannot enable the pre-major reporter path. ` + + 'Use an explicit --reporter option, or set RUSH_REPORTER=legacy for the emergency fallback.' + ); + } + if (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const ownsOutputControls: boolean = hasReporterOutputControl(argv); + const ownsLogLevelControls: boolean = hasReporterLogLevelControl(argv); + const candidateValueControls: IParsedReporterControls = + ownsOutputControls || ownsLogLevelControls ? parseReporterControls(argv, true) : selectionControls; + const ownsLogLevelControl: boolean = + ownsLogLevelControls && + !ownsOutputControls && + candidateValueControls.logLevels.length > 0 && + candidateValueControls.logLevels.every((logLevel: string) => isSupportedLogLevel(logLevel)); + const ownsValueControls: boolean = ownsOutputControls || ownsLogLevelControl; + const ownsEnvironmentControls: boolean = env.RUSH_LOG_LEVEL !== undefined; + const parsedValueControls: IParsedReporterControls = ownsValueControls + ? candidateValueControls + : selectionControls; + const outputControlsAreUnambiguous: boolean = + !parsedValueControls.logLevels.some((logLevel: string) => !isSupportedLogLevel(logLevel)) && + parsedValueControls.outputs.every((outputValue: string) => { + try { + const output: IReporterOutputTarget = parseOutputControl(outputValue); + return output.reporter === 'file' || output.reporter === 'json'; + } catch { + return false; + } + }); + const implicitControls: IParsedReporterControls = + ownsOutputControls && !outputControlsAreUnambiguous + ? { + ...parsedValueControls, + logLevels: [], + outputs: [] + } + : parsedValueControls; + validateReporterControlMultiplicity(implicitControls, ownsValueControls); + return { + reporter: commandJson ? 'file' : isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(implicitControls, env, true, true), + outputs: resolveOutputs(implicitControls.outputs, cwd), + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: + ownsLogLevelControl || ownsEnvironmentControls || implicitControls.outputs.length > 0, + reporterValueFlagsToStrip: + implicitControls.outputs.length > 0 + ? REPORTER_OUTPUT_VALUE_FLAGS + : ownsLogLevelControl + ? ['--log-level'] + : [], + reason: 'repository experiment' + }; + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + if (requestedReporter === 'legacy') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: REPORTER_SELECTION_FLAG, + reason: 'explicit --reporter' + }; + } + + if (commandJson && requestedReporter !== 'file') { + throw new Error( + `The command-specific --json output owns stdout and cannot be combined with --reporter=${requestedReporter}. ` + + 'Use --reporter=file or omit --reporter.' + ); + } + + const controls: IParsedReporterControls = parseReporterControls(argv, true); + validateReporterControlMultiplicity(controls, true); + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + if (requestedReporter === 'default' && !stdout.isTTY) { + throw new Error( + '--reporter=default requires an interactive TTY. Use --reporter=plaintext for CI or redirected output.' + ); + } + + return { + reporter: requestedReporter, + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + reason: 'explicit --reporter' + }; +} + +function createPrimaryReporter( + selection: IRushReporterSelection, + stdout: IRushReporterOutputStream, + env: Record +): IReporter | undefined { + switch (selection.reporter) { + case 'default': + return new DefaultInteractiveReporter({ + terminal: { + get columns() { + return stdout.columns && stdout.columns > 0 ? stdout.columns : 80; + }, + get isTTY() { + return stdout.isTTY === true; + }, + write: (text: string) => { + stdout.write(text); + } + }, + env + }); + case 'ai': + return new AiReporter({ write: (text: string) => stdout.write(text) }); + case 'json': + return new JsonReporter({ write: (text: string) => stdout.write(text) }); + case 'plaintext': + return new PlaintextReporter({ + write: (text: string) => stdout.write(text), + variant: selection.reason === 'explicit --reporter' || isCiDetected(env) ? 'detailed' : 'concise', + color: false, + logLevel: selection.logLevel + }); + case 'file': + return undefined; + case 'legacy': + return undefined; + } +} + +export async function initializeRushReporterHostAsync( + options: IRushReporterHostOptions = {} +): Promise { + const env: Record = options.env ?? process.env; + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + get columns() { + return process.stdout.columns; + }, + write: process.stdout.write.bind(process.stdout) + }; + const stderr: IRushReporterOutputStream = options.stderr ?? { + isTTY: process.stderr.isTTY, + columns: process.stderr.columns, + write: process.stderr.write.bind(process.stderr) + }; + const host: ReporterHost = new ReporterHost({ + env, + manager: options.manager, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; + let fullDetailReporter: FileReporter | undefined; + + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + + if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { + fullDetailReporter = new FileReporter({ + commonTempFolder: options.commonTempFolder, + actionName: options.actionName + }); + host.manager.addReporter(fullDetailReporter, { + destination: 'file:auto' + }); + } + + if (primaryReporter) { + const filteredReporter: IReporter = new LogLevelReporter( + primaryReporter, + selection.logLevel, + selection.reporter === 'plaintext' + ); + host.manager.addReporter( + selection.reporter === 'default' || selection.reporter === 'plaintext' + ? new VisibleBootstrapOutputFilterReporter(filteredReporter) + : filteredReporter, + { + destination: 'stdout' + } + ); + } + + if (selection.reporter === 'file') { + host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { + destination: 'stderr' + }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } + } + + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + const artifactCompletionSink: ArtifactCompletionReporterSink | undefined = fullDetailReporter + ? new ArtifactCompletionReporterSink(host, fullDetailReporter) + : undefined; + let sink: IReporterEventSink = artifactCompletionSink ?? host.getSink(); + if ( + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' + ) { + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + const incompatibility: string = + bootstrapReplay.skipReason === 'incompatible-protocol' + ? 'protocol is incompatible' + : 'contains an unsupported required event'; + throw new Error( + `The install-run-rush bootstrap reporter ${incompatibility} with this Rush frontend. ` + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); + } + + return { + host, + sink, + selection, + logArtifact: fullDetailReporter?.getArtifact(), + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= (async () => { + const fullyFlushed: boolean = await host.manager._flushAndConfirmAsync(timeoutMs); + if (fullyFlushed) { + await fullDetailReporter?.closeAsync(); + artifactCompletionSink?.publishIfChanged(); + } + await host.manager.closeAsync(timeoutMs); + })(); + return closePromise; + } + }; + } finally { + if (!handoffReplayAttempted) { + await host.discardBootstrapHandoffAsync(); + } + delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + } +} diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 615aaa0e356..258b4a5c111 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; +import { REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; -import { _FlagFile, _RushGlobalFolder, type ILaunchOptions } from '@microsoft/rush-lib'; +import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; import { RushCommandSelector } from './RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; const MAX_INSTALL_ATTEMPTS: number = 3; @@ -26,7 +28,7 @@ export class RushVersionSelector { public async ensureRushVersionInstalledAsync( version: string, configuration: MinimalRushConfiguration | undefined, - executeOptions: ILaunchOptions + executeOptions: IRushFrontendLaunchOptions ): Promise { const isLegacyRushVersion: boolean = semver.lt(version, '4.0.0'); const expectedRushPath: string = path.join(this._rushGlobalFolder.nodeSpecificPath, `rush-${version}`); @@ -38,16 +40,19 @@ export class RushVersionSelector { let installIsValid: boolean = await installMarker.isValidAsync(); if (!installIsValid) { // Need to install Rush - console.log(`Rush version ${version} is not currently installed. Installing...`); + this._reportStartupMessage( + executeOptions, + `Rush version ${version} is not currently installed. Installing...` + ); const resourceName: string = `rush-${version}`; - console.log(`Trying to acquire lock for ${resourceName}`); + this._reportStartupMessage(executeOptions, `Trying to acquire lock for ${resourceName}`); const lock: LockFile = await LockFile.acquireAsync(expectedRushPath, resourceName); installIsValid = await installMarker.isValidAsync(); if (installIsValid) { - console.log('Another process performed the installation.'); + this._reportStartupMessage(executeOptions, 'Another process performed the installation.'); } else { await Utilities.installPackageInDirectoryAsync({ directory: expectedRushPath, @@ -68,7 +73,10 @@ export class RushVersionSelector { filterNpmIncompatibleProperties: true }); - console.log(`Successfully installed Rush version ${version} in ${expectedRushPath}.`); + this._reportStartupMessage( + executeOptions, + `Successfully installed Rush version ${version} in ${expectedRushPath}.` + ); // If we've made it here without exception, write the flag file await installMarker.createAsync(); @@ -100,4 +108,19 @@ export class RushVersionSelector { RushCommandSelector.execute(this._currentPackageVersion, rushCliEntrypoint, executeOptions); } } + + private _reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { + if (options.reporterEnabled) { + options.reporter.eventSink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId: options.reporter.sessionId, + source: { packageName: '@microsoft/rush', packageVersion: this._currentPackageVersion }, + privacy: 'public', + type: 'activityChanged', + payload: { kind: 'version-selection', text } + }); + } else { + console.log(text); + } + } } diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index bba3469421f..eda177e33c3 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -7,7 +7,7 @@ import * as rushLib from '@microsoft/rush-lib'; import { PackageJsonLookup, Import } from '@rushstack/node-core-library'; -import { RushCommandSelector } from './RushCommandSelector'; +import { launchRushFrontendAsync } from './RushFrontend'; const builtInPluginConfigurations: rushLib._IBuiltInPluginConfiguration[] = []; @@ -34,8 +34,17 @@ includePlugin('rush-serve-plugin'); includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; -RushCommandSelector.execute(currentPackageVersion, rushLib, { - isManaged: false, - alreadyReportedNodeTooNewError: false, - builtInPluginConfigurations +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { + isManaged: false, + alreadyReportedNodeTooNewError: false, + builtInPluginConfigurations + }, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(error); }); diff --git a/apps/rush/src/start.ts b/apps/rush/src/start.ts index bf8d5927230..01e8d90f28e 100644 --- a/apps/rush/src/start.ts +++ b/apps/rush/src/start.ts @@ -29,9 +29,9 @@ import { EnvironmentVariableNames } from '@microsoft/rush-lib'; import type { ILaunchOptions } from '@microsoft/rush-lib'; import * as rushLib from '@microsoft/rush-lib'; -import { RushCommandSelector } from './RushCommandSelector'; -import { RushVersionSelector } from './RushVersionSelector'; import { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import { launchRushFrontendAsync } from './RushFrontend'; +import { getRushPreviewVersion } from './RushPreviewVersion'; // Load the configuration const configuration: MinimalRushConfiguration | undefined = @@ -41,7 +41,7 @@ const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dir let rushVersionToLoad: string | undefined = undefined; -const previewVersion: string | undefined = process.env[EnvironmentVariableNames.RUSH_PREVIEW_VERSION]; +const previewVersion: string | undefined = getRushPreviewVersion(); if (previewVersion) { if (!semver.valid(previewVersion, false)) { @@ -90,16 +90,13 @@ const terminalProvider: ITerminalProvider = new ConsoleTerminalProvider(); const launchOptions: ILaunchOptions = { isManaged, alreadyReportedNodeTooNewError, terminalProvider }; -// If we're inside a repo folder, and it's requesting a different version, then use the RushVersionManager to -// install it -if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { - const versionSelector: RushVersionSelector = new RushVersionSelector(currentPackageVersion); - versionSelector - .ensureRushVersionInstalledAsync(rushVersionToLoad, configuration, launchOptions) - .catch((error: Error) => { - console.log(Colorize.red('Error: ' + error.message)); - }); -} else { - // Otherwise invoke the rush-lib that came with this rush package - RushCommandSelector.execute(currentPackageVersion, rushLib, launchOptions); -} +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(Colorize.red(`Error: ${error.message}`)); +}); diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 391c9feeeb2..3f2e1e847ea 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -3,11 +3,36 @@ import * as path from 'node:path'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { PackageJsonLookup } from '@rushstack/node-core-library'; + import { MinimalRushConfiguration } from '../MinimalRushConfiguration'; describe(MinimalRushConfiguration.name, () => { + const originalArgv: string[] = process.argv; + const originalRushTempFolder: string | undefined = process.env.RUSH_TEMP_FOLDER; + const originalRushPreviewVersion: string | undefined = process.env.RUSH_PREVIEW_VERSION; + const originalRushReporter: string | undefined = process.env.RUSH_REPORTER; + afterEach(() => { jest.restoreAllMocks(); + process.argv = originalArgv; + if (originalRushTempFolder === undefined) { + delete process.env.RUSH_TEMP_FOLDER; + } else { + process.env.RUSH_TEMP_FOLDER = originalRushTempFolder; + } + if (originalRushPreviewVersion === undefined) { + delete process.env.RUSH_PREVIEW_VERSION; + } else { + process.env.RUSH_PREVIEW_VERSION = originalRushPreviewVersion; + } + if (originalRushReporter === undefined) { + delete process.env.RUSH_REPORTER; + } else { + process.env.RUSH_REPORTER = originalRushReporter; + } + EnvironmentConfiguration.reset(); }); describe('legacy rush config', () => { @@ -19,6 +44,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('2.5.0'); + expect(config.useRushReporter).toBe(false); }); }); @@ -31,6 +57,177 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); + expect(config.useRushReporter).toBe(true); + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'common', 'temp')); + }); + + it('uses the normalized RUSH_TEMP_FOLDER override', () => { + process.env.RUSH_TEMP_FOLDER = path.join( + __dirname, + 'sandbox', + 'repo', + 'custom-temp', + '..', + 'rush-temp' + ); + EnvironmentConfiguration.reset(); + + const config: MinimalRushConfiguration = + MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; + + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'rush-temp')); + }); + }); + + it('preserves legacy discovery text and blank-line behavior exactly', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '2.5.0' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--verbose']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it('prints the legacy discovery line and blank line when rush.json is in the current folder', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(legacyRepo); + process.argv = ['node', 'rush', 'build', '--verbose']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it('suppresses legacy discovery output for an explicit reporter', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '2.5.0' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--verbose', '--reporter=json']; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('restores legacy discovery output under the emergency fallback', () => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', 'build', '--reporter=json']; + process.env.RUSH_REPORTER = 'legacy'; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it.each(['json', 'ai', 'file'])( + 'keeps discovery off stdout when an incompatible engine rejects %s', + (reporter) => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '5.178.1' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + delete process.env.RUSH_REPORTER; + process.argv = ['node', 'rush', 'build', `--reporter=${reporter}`]; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['environment fallback', ['build', '--reporter=json'], 'legacy'], + ['explicit legacy reporter', ['build', '--reporter=legacy'], undefined], + ['help fallback', ['build', '--reporter=json', '--help'], undefined], + ['cross-version fallback', ['build'], undefined] + ])('restores legacy discovery output in an opted-in repository for %s', (testName, args, envValue) => { + void testName; + const repo: string = path.join(__dirname, 'sandbox', 'repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(repo, 'project')); + process.argv = ['node', 'rush', ...args]; + if (envValue === undefined) { + delete process.env.RUSH_REPORTER; + } else { + process.env.RUSH_REPORTER = envValue; + } + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([[`Found configuration in ${path.join(repo, 'rush.json')}`], ['']]); + }); + + it.each([ + ['custom reporter value', ['custom', '--reporter', 'junit']], + ['value-less custom reporter flag', ['custom', '--reporter', '--verbose']], + ['pass-through reporter flag', ['build', '--', '--reporter=json']] + ])('preserves legacy discovery output for %s', (testName, args) => { + void testName; + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + process.argv = ['node', 'rush', ...args]; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog.mock.calls).toEqual([ + [`Found configuration in ${path.join(legacyRepo, 'rush.json')}`], + [''] + ]); + }); + + it('uses the effective preview version when deciding discovery ownership', () => { + const repo: string = path.join(__dirname, 'sandbox', 'repo'); + const consoleLog: jest.SpiedFunction = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '5.178.1' }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(repo, 'project')); + process.argv = ['node', 'rush', 'build', '--reporter=json']; + process.env.RUSH_PREVIEW_VERSION = '5.178.1'; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); }); }); diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 00000000000..eb7e9383e98 --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,536 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + LegacyFallbackSink, + ReporterManager, + REPORTER_PROTOCOL_VERSION, + type IReporter, + type IReporterEventEnvelope +} from '@rushstack/rush-reporter'; +import { Rush } from '@microsoft/rush-lib'; + +import { RushCommandSelector } from '../RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly events: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise {} + + public report(event: IReporterEventEnvelope): void { + this.events.push(event); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +class WritingReporter implements IReporter { + public readonly name: string = 'writing'; + public reportCount: number = 0; + + public async initializeAsync(): Promise {} + + public report(): void { + this.reportCount++; + process.stdout.write('reporter output\n'); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +type BeforeExitListener = (code: number) => void; + +function restoreObservedOutput( + previousBeforeExitListeners: readonly BeforeExitListener[], + required: boolean = true +): void { + const currentListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + const restoreListener: BeforeExitListener | undefined = currentListeners.find( + (listener: BeforeExitListener) => !previousBeforeExitListeners.includes(listener) + ); + if (!restoreListener) { + if (required) { + throw new Error('Expected an old-engine output restoration listener.'); + } + return; + } + restoreListener(0); +} + +describe(RushCommandSelector.name, () => { + it('publishes the current engine reporter protocol major', () => { + expect((Rush as typeof Rush & { readonly _reporterProtocolMajor?: number })._reporterProtocolMajor).toBe( + REPORTER_PROTOCOL_VERSION.major + ); + }); + + it('does not observe output from a matching structured engine when reporters are disabled', () => { + const manager: ReporterManager = new ReporterManager(); + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: false, + reporterSelectionReason: 'explicit --reporter' + }; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const currentRushLib = { + Rush: { + version: '5.178.1', + _reporterProtocolMajor: REPORTER_PROTOCOL_VERSION.major, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', currentRushLib, options); + + expect(process.stdout.write).toBe(originalStdoutWrite); + expect(receivedOptions?.reporter).toBe(options.reporter); + }); + + it('does not recapture reporter output while observing an old engine', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: WritingReporter = new WritingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => process.stdout.write('legacy output\n') + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.reportCount).toBe(1); + expect(stdoutText.match(/reporter output/g)).toHaveLength(1); + expect(stdoutText.match(/legacy output/g)).toHaveLength(1); + }); + + it('keeps ordered old-engine stdout and stderr on their original streams', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + let stderrText: string = ''; + process.argv = ['node', 'rush', 'build']; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = ((text: string): boolean => { + stderrText += text; + return true; + }) as typeof process.stderr.write; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('stdout 1\n'); + process.stderr.write('stderr 1\n'); + process.stdout.write('stdout 2\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, options); + expect(process.stdout.write).not.toBe(stdoutWrite); + expect(process.stderr.write).not.toBe(stderrWrite); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + + it('captures asynchronous old-engine output until the process lifecycle completes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + setImmediate(() => { + process.stdout.write('async stdout\n'); + process.stderr.write('async stderr\n'); + }); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + await new Promise((resolve) => setImmediate(resolve)); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'async stdout\n', wasRendered: true }, + { stream: 'stderr', text: 'async stderr\n', wasRendered: true } + ]); + }); + + it.each([undefined, REPORTER_PROTOCOL_VERSION.major])( + 'keeps engine stdout structured for machine reporters (protocol %s)', + async (protocolMajor) => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + let closeFromEngine: (() => Promise) | undefined; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: protocolMajor, + launch: (version: string, options: IRushFrontendLaunchOptions) => { + void version; + closeFromEngine = options.reporterCloseAsync; + process.stdout.write('legacy stdout\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => { + expect(process.stdout.write).toBe(stdoutWrite); + await manager.closeAsync(); + }, + reporterEnabled: true, + reporterStdoutIsMachineReadable: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + expect(closeFromEngine).toBeDefined(); + await closeFromEngine!(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe(''); + expect(reporter.events[0].payload).toEqual({ + stream: 'stdout', + text: 'legacy stdout\n' + }); + } + ); + + it('preserves a UTF-8 code point split across old-engine buffer writes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write(Buffer.from([0xe2])); + process.stdout.write(Buffer.from([0x82, 0xac])); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€', wasRendered: true }); + }); + + it('restores old-engine stream writers when launch throws', () => { + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + + try { + expect(() => + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + throw new Error('launch failed'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ) + ).toThrow('launch failed'); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + }); + + it('fails an explicit reporter request for an incompatible new engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 2/ + ); + }); + + it('fails an explicit reporter request for an incompatible older engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 0/ + ); + }); + + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); + }); + + it('falls back to legacy engine rendering for an implicit older protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); + }); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts new file mode 100644 index 00000000000..a59304d00ac --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,1173 @@ +// 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 os from 'node:os'; +import * as path from 'node:path'; + +import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { LockFile } from '@rushstack/node-core-library'; +import { + ReporterHost, + ReporterManager, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink +} from '@rushstack/rush-reporter'; + +import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; +import { + initializeRushReporterHostAsync, + type IInitializedRushReporterHost, + type IRushReporterSelection +} from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +async function createInitializedHostAsync( + order: string[], + reason: IInitializedRushReporterHost['selection']['reason'] = 'pre-major legacy default' +): Promise { + order.push('host'); + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + let closePromise: Promise | undefined; + const hasExplicitReporter: boolean = reason === 'explicit --reporter'; + return { + host, + sink: host.getSink(), + logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reporterControlsOwnedByFrontend: hasExplicitReporter, + reporterValueFlagsToStrip: hasExplicitReporter ? ['--reporter'] : [], + reason + }, + closeAsync: (timeoutMs?: number) => { + if (!closePromise) { + order.push('close'); + closePromise = host.manager.closeAsync(timeoutMs); + } + return closePromise; + } + }; +} + +async function createEnabledHostAsync( + closeAsync?: (timeoutMs?: number) => Promise +): Promise { + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + return { + host, + sink: host.getSink(), + logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: closeAsync ?? ((timeoutMs?: number) => host.manager.closeAsync(timeoutMs)) + }; +} + +async function createPhaseHangingHostAsync( + hangingPhase: 'flush' | 'close' +): Promise { + const never: Promise = new Promise(() => undefined); + const reporter: IReporter = { + name: `hang-${hangingPhase}`, + initializeAsync: async (context: IReporterContext) => { + void context; + }, + report: (event: IReporterEventEnvelope) => { + void event; + }, + flushAsync: () => (hangingPhase === 'flush' ? never : Promise.resolve()), + closeAsync: () => (hangingPhase === 'close' ? never : Promise.resolve()) + }; + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(reporter); + const host: ReporterHost = new ReporterHost({ env: {}, manager }); + await manager.initializeAsync(); + let closePromise: Promise | undefined; + return { + host, + sink: host.getSink(), + logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: (timeoutMs?: number) => { + closePromise ??= manager.closeAsync(timeoutMs); + return closePromise; + } + }; +} + +interface ITestProcessLifecycle extends IRushFrontendProcessLifecycle { + beforeExitListener: (() => void) | undefined; + readonly signalListeners: Map<'SIGINT' | 'SIGTERM', () => void>; + readonly terminatedSignals: Array<'SIGINT' | 'SIGTERM'>; + readonly exitCodes: number[]; + readonly closeErrors: Error[]; +} + +function createTestProcessLifecycle(): ITestProcessLifecycle { + const lifecycle: ITestProcessLifecycle = { + beforeExitListener: undefined, + signalListeners: new Map(), + terminatedSignals: [], + exitCodes: [], + closeErrors: [], + registerBeforeExit: (listener: () => void) => { + lifecycle.beforeExitListener = listener; + return () => { + if (lifecycle.beforeExitListener === listener) { + lifecycle.beforeExitListener = undefined; + } + }; + }, + registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => { + lifecycle.signalListeners.set(signal, listener); + return () => { + if (lifecycle.signalListeners.get(signal) === listener) { + lifecycle.signalListeners.delete(signal); + } + }; + }, + terminate: (signal: 'SIGINT' | 'SIGTERM') => { + lifecycle.terminatedSignals.push(signal); + }, + setExitCode: (exitCode: number) => { + lifecycle.exitCodes.push(exitCode); + }, + reportCloseError: (error: Error) => { + lifecycle.closeErrors.push(error); + } + }; + return lifecycle; +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +function releaseParserTestLocks(spy: jest.SpiedFunction): void { + try { + // Native parser locks live until process exit; these fixtures run the parser inside Jest instead. + for (const result of spy.mock.results) { + if (result.type === 'return' && result.value && !result.value.isReleased) result.value.release(); + } + } finally { + spy.mockRestore(); + } +} + +describe(launchRushFrontendAsync.name, () => { + it.each([ + ['file', false, true], + ['file', true, false], + ['json', false, true], + ['ai', false, true], + ['plaintext', false, false] + ] as const)('reserves stdout for %s with command JSON %s: %s', async (reporter, commandJson, reserved) => { + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'list']; + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => ({ + ...initialized, + selection: { ...initialized.selection, reporter, commandJson } + }), + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + expect(options.reporterStdoutIsReserved).toBe(reserved); + return options.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + } finally { + await initialized.closeAsync(); + process.argv = originalArgv; + } + }); + + it('creates the authoritative host before invoking the bundled rush-lib and passes only its channel', async () => { + const order: string[] = []; + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order, 'explicit --reporter'), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + order.push('engine'); + receivedOptions = launchOptions; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(order).toEqual(['host', 'engine', 'close']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String), + operationStreamEnabled: false + }); + expect(receivedOptions).not.toHaveProperty('selection'); + expect(receivedOptions).not.toHaveProperty('host'); + expect(receivedOptions).not.toHaveProperty('manager'); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend', + operationStreamEnabled: false + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + + it('keeps an active purge reporter log outside the temp folder being purged', async () => { + const order: string[] = []; + let commonTempFolder: string | undefined = 'not-captured'; + let actionName: string | undefined; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'purge', '--reporter=file']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { + commonTempFolder: '/repo/common/temp', + useRushReporter: false + } as MinimalRushConfiguration, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + commonTempFolder = options.commonTempFolder; + actionName = options.actionName; + return createInitializedHostAsync(order, 'explicit --reporter'); + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(actionName).toBe('purge'); + expect(commonTempFolder).toBeUndefined(); + } finally { + process.argv = originalArgv; + } + }); + + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + const createVersionSelector: jest.Mock = jest.fn(); + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined } + }), + createVersionSelector, + processLifecycle + }) + ).rejects.toThrow(/selected Rush engine 5\.177\.0 cannot safely use --reporter=json/); + + expect(createVersionSelector).not.toHaveBeenCalled(); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + await expect(fs.promises.stat(outputPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('keeps an implicit repository opt-in on the legacy path for an incompatible engine', async () => { + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + receivedArgv = [...process.argv]; + await launchOptions.reporterCloseAsync(); + }; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + name: 'unsupported custom reporter', + reporter: 'junit', + expectedArgv: [ + 'node', + 'rush', + 'custom', + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ] + }, + { + name: 'explicit legacy reporter', + reporter: 'legacy', + expectedArgv: ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'] + } + ])('preserves the old-engine $name escape path', async ({ reporter, expectedArgv }) => { + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + receivedArgv = [...process.argv]; + await launchOptions.reporterCloseAsync(); + }; + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + 'rush', + 'custom', + '--reporter', + reporter, + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(receivedArgv).toEqual(expectedArgv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + name: 'unsupported reporter as a custom value', + reporter: 'junit', + env: {}, + expectedArguments: [ + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + expectedEnabled: false + }, + { + name: 'supported reporter as frontend ownership', + reporter: 'json', + env: {}, + expectedArguments: ['--verbose'], + expectedEnabled: true + }, + { + name: 'explicit legacy under the emergency override', + reporter: 'legacy', + env: { RUSH_REPORTER: 'legacy' }, + expectedArguments: ['--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + expectedEnabled: false + } + ])('runs the real custom command fixture with $name', async (testCase) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-command-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const reporterOutputPath: string = path.join(directory, 'reporter.jsonl'); + const outputValue: string = testCase.reporter === 'json' ? `json://${reporterOutputPath}` : 'custom.zip'; + const logLevelValue: string = testCase.reporter === 'json' ? 'debug' : 'custom'; + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = [ + 'node', + 'rush', + 'custom-output', + '--reporter', + testCase.reporter, + '--output', + outputValue + ]; + if (testCase.reporter !== 'json') { + process.argv.push('--log-level', logLevelValue); + } + process.argv.push('--verbose'); + let selection: IRushReporterSelection | undefined; + const lockSpy = jest.spyOn(LockFile, 'tryAcquire'); + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: testCase.env, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection?.enabled).toBe(testCase.expectedEnabled); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-output-args.json'), 'utf8')) + ).toEqual(testCase.expectedArguments); + expect( + lockSpy.mock.results.some( + (result) => result.type === 'return' && result.value && !result.value.isReleased + ) + ).toBe(true); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + releaseParserTestLocks(lockSpy); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects an unsupported reporter typo when repository opt-in establishes ownership', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom-output', '--reporter=junit']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unsupported reporter "junit"'); + } finally { + process.argv = originalArgv; + } + }); + + it('runs a value-less custom reporter flag through the real frontend and parser boundary', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-reporter-flag-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'custom-reporter-flag', '--reporter']; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let selection: IRushReporterSelection | undefined; + const lockSpy = jest.spyOn(LockFile, 'tryAcquire'); + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle + }); + + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-reporter-flag-args.json'), 'utf8')) + ).toEqual(['--reporter']); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + expect( + lockSpy.mock.results.some( + (result) => result.type === 'return' && result.value && !result.value.isReleased + ) + ).toBe(true); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + releaseParserTestLocks(lockSpy); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('flushes and closes an explicit output through the real frontend boundary on success', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporter.eventSink); + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('flushes an explicit output before the parser process.exit backstop', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-parser-exit-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + let outputAtExit: string | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporter.eventSink); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); + Object.defineProperty(parser, '_rushOptions', { + value: { reporterCloseAsync: launchOptions.reporterCloseAsync } + }); + process.exitCode = 1; + + return new Promise((resolve: () => void) => { + jest.spyOn(process, 'exit').mockImplementation(() => { + outputAtExit = fs.readFileSync(outputPath, 'utf8'); + resolve(); + return undefined as never; + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + ( + parser as unknown as { + _reportErrorAndSetExitCode(error: Error): void; + } + )._reportErrorAndSetExitCode(new Error('parser failed')); + }); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(outputAtExit!).type).toBe('commandStarted'); + } finally { + jest.restoreAllMocks(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('preserves pass-through arguments byte-for-byte through the real frontend boundary', async () => { + const originalArgv: string[] = process.argv; + const passThroughArguments: string[] = [ + '--', + '--reporter=unknown', + '--reporter', + 'tool-reporter', + '--output=not-a-url', + '--output', + 'tool-output', + '--log-level=loud', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + process.argv = ['node', 'rush', 'build', '--reporter=json', ...passThroughArguments]; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'json', + logLevel: 'normal', + commandJson: false, + enabled: true + }); + expect(receivedArgv).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + } finally { + process.argv = originalArgv; + } + }); + + it('preserves custom value parameters when repository opt-in enables reporting', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + } finally { + process.argv = originalArgv; + } + }); + + it('closes exactly once when the engine rejects', async () => { + const closeAsync: jest.Mock, [number?]> = jest.fn(async () => undefined); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('engine rejected')), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('engine rejected'); + + expect(closeAsync).toHaveBeenCalledTimes(1); + }); + + it('closes exactly once when command selection fails', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: {} as typeof import('@microsoft/rush-lib'), + initializeReporterHostAsync: async () => initialized, + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unable to find the "Rush" entry point'); + + expect(order).toEqual(['host', 'close']); + } finally { + process.argv = originalArgv; + } + }); + + it('preserves the command failure when reporter close also fails', async () => { + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(async () => { + throw new Error('close failed'); + }); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('command failed')), + processLifecycle + }) + ).rejects.toThrow('command failed'); + + expect(processLifecycle.exitCodes).toEqual([1]); + expect(processLifecycle.closeErrors).toEqual([expect.objectContaining({ message: 'close failed' })]); + }); + + it.each(['rush', 'rushx', 'rush-pnpm'])( + 'does not install lifecycle listeners for the disabled %s path', + async (commandName) => { + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + commandName, + 'custom', + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let receivedArgv: string[] | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + commandName: commandName as 'rush' | 'rushx' | 'rush-pnpm', + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + } + ); + + it('uses a bounded close before preserving signal termination', async () => { + let resolveClose: (() => void) | undefined; + const closePromise: Promise = new Promise((resolve: () => void) => { + resolveClose = resolve; + }); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => closePromise); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledWith(2000); + expect(processLifecycle.terminatedSignals).toEqual([]); + + resolveClose!(); + await closePromise; + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.signalListeners.size).toBe(0); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + }); + + it('enforces the signal deadline when a longer close is already in flight', async () => { + jest.useFakeTimers(); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => new Promise(() => undefined)); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + void launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledWith(undefined); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + expect(closeAsync).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it.each(['flush', 'close'] as const)( + 'uses one signal deadline when the reporter %s phase hangs', + async (hangingPhase) => { + jest.useFakeTimers(); + const initialized: IInitializedRushReporterHost = await createPhaseHangingHostAsync(hangingPhase); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGINT')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGINT']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + } finally { + jest.useRealTimers(); + } + } + ); +}); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts new file mode 100644 index 00000000000..5fc9fcff7fd --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,1219 @@ +// 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 os from 'node:os'; +import * as path from 'node:path'; + +import { + ReporterManager, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink, + BootstrapEventBuffer, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync +} from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IInitializedRushReporterHost, + type IRushReporterOutputStream, + type IRushReporterSelection +} from '../RushReporterHost'; + +function resolve( + argv: readonly string[], + env: Record = {}, + isTTY: boolean = false, + repositoryOptIn: boolean = false, + forceLegacy: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn, + forceLegacy, + selectedRushVersion: forceLegacy ? '5.177.0' : undefined + }); +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +function emitOperationEvents(sink: IReporterEventSink): void { + const base = { + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + scope: { commandName: 'build', operationId: 'project#phase' } + } as const; + sink.emit({ + ...base, + privacy: 'public', + type: 'operationRegistered', + payload: { operationId: 'project#phase', projectName: 'project', phaseName: 'phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStatusChanged', + payload: { operationId: 'project#phase', previousStatus: 'queued', status: 'executing' } + }); + sink.emit({ + ...base, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stdout', text: 'raw operation output\n' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStreamClosed', + payload: { operationId: 'project#phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationCompleted', + payload: { operationId: 'project#phase', status: 'success' } + }); +} + +describe(resolveRushReporterSelection.name, () => { + it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { + for (const testCase of [ + { env: {}, isTTY: true }, + { env: {}, isTTY: false }, + { env: { CI: 'true' }, isTTY: false }, + { env: { COPILOT_CLI: '1' }, isTTY: true } + ]) { + expect(resolve(['build'], testCase.env, testCase.isTTY)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }); + } + }); + + it('requires an explicit non-legacy --reporter to opt in', () => { + expect(resolve(['build', '--reporter=json'], { CI: 'true' }, false)).toMatchObject({ + reporter: 'json', + enabled: true, + reason: 'explicit --reporter' + }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); + }); + + it('uses deterministic non-agent selection for the repository experiment', () => { + expect(resolve(['build'], {}, true, true)).toMatchObject({ + reporter: 'default', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { CI: 'true' }, true, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { COPILOT_CLI: '1' }, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build', '--quiet', '--verbose', '--debug'], {}, false, true).logLevel).toBe('debug'); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve( + ['build', '--reporter=plaintext', '--log-level=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + + it('owns standalone log-level controls when the repository experiment is enabled', () => { + expect(resolve(['build', '--log-level=debug'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--log-level'] + }); + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'debug' }, false, true)).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: [] + }); + }); + + it('preserves custom value parameters when the repository experiment selects the reporter implicitly', () => { + expect( + resolve( + ['custom', '--output', 'artifact.zip', '--log-level', 'custom-level', '--verbose'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + }); + + it('does not consume rush-pnpm or rushx reporter arguments', () => { + expect( + resolveRushReporterSelection({ + argv: ['install', '--reporter=append-only'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rush-pnpm' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + expect( + resolveRushReporterSelection({ + argv: ['build', '--reporter=custom-script-value'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rushx' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + }); + + it('keeps RUSH_REPORTER=legacy as an emergency override', () => { + expect( + resolve( + ['build', '--reporter=json', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'RUSH_REPORTER=legacy' + }); + + const legacySelection: IRushReporterSelection = resolve( + ['custom', '--reporter=legacy', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + { RUSH_REPORTER: 'legacy' } + ); + expect(legacySelection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterValueFlagsToStrip: ['--reporter'], + reason: 'RUSH_REPORTER=legacy' + }); + expect( + stripReporterValueControls( + [ + 'node', + 'rush', + 'custom', + '--reporter=legacy', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + new Set(legacySelection.reporterValueFlagsToStrip) + ) + ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); + }); + + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + + it('ignores help controls after the pass-through separator', () => { + expect(resolve(['build', '--reporter=json', '--', '--help'])).toMatchObject({ + reporter: 'json', + enabled: true, + reporterControlsOwnedByFrontend: true + }); + }); + + it('removes reporter-only value controls before invoking a legacy engine', () => { + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'list', + '--json', + '--reporter=json', + '--output', + 'file://./rush.log', + '--log-level=debug', + '--quiet' + ]) + ).toEqual(['node', 'rush', 'list', '--json', '--quiet']); + }); + + it('preserves every argument at and after the pass-through separator', () => { + const passThroughArguments: string[] = [ + '--', + '--reporter=tool-reporter', + '--reporter', + 'tool-reporter', + '--output=tool-output', + '--output', + 'tool-output', + '--log-level=tool-level', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'build', + '--reporter=json', + '--output', + 'json://./events.jsonl', + '--log-level=debug', + ...passThroughArguments + ]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + expect( + stripReporterValueControls(['node', 'rush', 'build', '--reporter', ...passThroughArguments]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + }); + + it('ignores reporter controls and aliases after the pass-through separator', () => { + expect( + resolve([ + 'build', + '--', + '--reporter=unknown', + '--output=not-a-url', + '--log-level=loud', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary' + ]) + ).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reason: 'pre-major legacy default' + }); + expect( + resolve(['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid']) + ).toMatchObject({ reporter: 'json', enabled: true }); + }); + + it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { + expect( + resolve(['build', '--reporter=plaintext', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel + ).toBe('verbose'); + expect(resolve(['build', '--reporter=plaintext'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe('debug'); + expect(() => resolve(['build', '--reporter=plaintext', '--quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + }); + + it('preserves RUSH_QUIET_MODE as a quiet reporter alias', () => { + expect(resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: 'true' }).logLevel).toBe('quiet'); + expect(() => + resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: '1', RUSH_LOG_LEVEL: 'debug' }) + ).toThrow(/contradicts RUSH_LOG_LEVEL/); + }); + + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter'] + }); + }); + + it('ignores reporter environment selection before the gate and preserves custom value controls', () => { + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); + expect(resolve(['custom', '--reporter=junit'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(() => resolve(['custom', '--reporter=junit'], {}, false, true)).toThrow( + /Unsupported reporter "junit"/ + ); + expect(() => resolve(['custom', '--reporter=junit', '--output=json://./events.jsonl'])).toThrow( + /Unsupported reporter "junit"/ + ); + expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); + expect(resolve(['custom', '--output=json://events.jsonl', '--log-level=custom'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + }); + + it('probes value-less custom reporter flags without claiming ownership', () => { + expect(resolve(['custom', '--reporter'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['custom', '--reporter', '--verbose'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(() => resolve(['custom', '--reporter'], {}, false, true)).toThrow(/--reporter requires a value/); + expect(() => resolve(['custom', '--reporter', '--output=json://./events.jsonl'])).toThrow( + /--reporter requires a value/ + ); + expect(() => resolve(['custom', '--reporter=json', '--reporter'])).toThrow(/--reporter requires a value/); + }); + + it('rejects explicit non-legacy reporters for incompatible selected engines', () => { + expect(() => resolve(['build', '--reporter=json'], {}, false, true, true)).toThrow( + /selected Rush engine 5\.177\.0 cannot safely use --reporter=json/ + ); + expect(resolve(['custom', '--reporter=junit', '--verbose'], {}, false, false, true)).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }); + }); + + it('rejects an interactive reporter on non-TTY output', () => { + expect(() => resolve(['build', '--reporter=default'], {}, false)).toThrow(/requires an interactive TTY/); + expect(resolve(['build', '--reporter=default'], {}, true).reporter).toBe('default'); + }); + + it('preserves command-specific --json as the sole stdout owner', () => { + expect(() => resolve(['list', '--json', '--reporter=json'])).toThrow( + /command-specific --json output owns stdout/ + ); + + const selection: IRushReporterSelection = resolve( + ['list', '--json', '--output=file://./rush.log?logLevel=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ); + + expect(selection).toMatchObject({ + commandJson: true, + reporter: 'file', + enabled: true, + reason: 'repository experiment' + }); + expect(selection.outputs).toEqual([ + { + reporter: 'file', + target: path.resolve('/repo', 'rush.log'), + params: { logLevel: 'debug' } + }, + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl'), + params: {} + } + ]); + expect(selection).toMatchObject({ + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--output', '--log-level'] + }); + expect(resolve(['list', '--json', '--reporter=file']).reporter).toBe('file'); + }); + + it('forces legacy selection for an incompatible Rush engine', () => { + expect(() => + resolveRushReporterSelection({ + argv: ['build', '--reporter=json'], + env: {}, + forceLegacy: true + }) + ).toThrow(/cannot safely use --reporter=json/); + }); + + it('surfaces unsupported and incomplete controls with actionable errors', () => { + expect(() => resolve(['build', '--reporter=json', '--reporter=ai'])).toThrow( + /may be specified only once/ + ); + expect(() => resolve(['build', '--reporter=json', '--log-level=quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + expect(() => resolve(['build', '--reporter=json', '--output=plaintext://./output.txt'])).toThrow( + /supports file:\/\/ and json:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { + let output: string = ''; + const stdout: IRushReporterOutputStream = { + isTTY: false, + write: (text: string) => { + output += text; + } + }; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env: { CI: 'true', COPILOT_CLI: '1' }, + stdout, + includeDefaultFileReporter: false + }); + + const sink: IReporterEventSink = initialized.sink; + emitCommandStarted(sink); + await initialized.closeAsync(); + + expect(initialized.selection.enabled).toBe(false); + expect(initialized.logArtifact).toBeUndefined(); + expect(output).toBe(''); + }); + + it('always creates a repository full-detail log on the enabled path', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-full-log-')); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined } + }); + + expect(initialized.logArtifact).toMatchObject({ available: true }); + expect(initialized.logArtifact?.path).toMatch( + new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + await initialized.closeAsync(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('prints the file path for a parser-only failure without commandResult', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-only-')); + let stderrText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['missing-command', '--reporter=file'], + env: {}, + commonTempFolder: directory, + actionName: 'missing-command', + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'sessionCompleted', + payload: { exitCode: 1 } + }); + await initialized.closeAsync(); + + expect(stderrText.match(/Rush full log:/g)).toHaveLength(1); + expect(stderrText).toContain(initialized.logArtifact?.path); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not render operation output at quiet plaintext log level', async () => { + let output: string = ''; + const quietHost = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext', '--log-level=quiet'], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + output += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(quietHost.sink); + emitOperationEvents(quietHost.sink); + quietHost.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await quietHost.closeAsync(); + + expect(output).not.toContain('raw operation output'); + expect(output).toContain('rush build succeeded'); + }); + + it('initializes the explicitly selected reporter and output destinations', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: [ + 'build', + '--reporter=json', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(initialized.sink); + emitOperationEvents(initialized.sink); + const firstClose: Promise = initialized.closeAsync(); + expect(initialized.closeAsync()).toBe(firstClose); + await firstClose; + + const stdoutEvents: Record[] = stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + const fileEvents: Record[] = (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(stdoutEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); + expect(fileEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('publishes a completed artifact before the final AI record', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-ai-artifact-')); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=ai'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + } + }); + + emitCommandStarted(initialized.sink); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await initialized.closeAsync(); + + const finalRecord: { log?: { complete?: boolean; path?: string } } = JSON.parse( + stdoutText.trim().split('\n').at(-1)! + ); + expect(finalRecord.log).toMatchObject({ + complete: true, + path: initialized.logArtifact?.path + }); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('observes terminal resizing after binding the default stdout writer', async () => { + const originalColumns: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + process.stdout, + 'columns' + ); + const originalIsTTY: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY' + ); + let output: string = ''; + const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += chunk.toString(); + return true; + }); + let initialized: IInitializedRushReporterHost | undefined; + try { + Object.defineProperty(process.stdout, 'columns', { configurable: true, writable: true, value: 80 }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env: { NO_COLOR: '' }, + includeDefaultFileReporter: false + }); + const activity: string = 'a'.repeat(60); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'activityChanged', + payload: { text: activity } + }); + await initialized.host.manager.flushAsync(); + expect(output).toContain(activity); + + output = ''; + process.stdout.columns = 20; + await initialized.host.manager.flushAsync(); + expect(output).not.toContain(activity); + expect(output).toContain('a'.repeat(19)); + } finally { + await initialized?.closeAsync(); + writeSpy.mockRestore(); + for (const [property, descriptor] of [ + ['columns', originalColumns], + ['isTTY', originalIsTTY] + ] as const) { + if (descriptor) { + Object.defineProperty(process.stdout, property, descriptor); + } else { + Reflect.deleteProperty(process.stdout, property); + } + } + } + }); + + it('publishes a frozen complete artifact after archiving replayed bootstrap output', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-artifact-snapshot-')); + const reported: IReporterEventEnvelope[] = []; + const manager: ReporterManager = new ReporterManager(); + const captureReporter: IReporter = { + name: 'capture', + initializeAsync: async (context: IReporterContext) => { + void context; + }, + report: (event: IReporterEventEnvelope) => { + reported.push(event); + }, + flushAsync: async () => undefined, + closeAsync: async () => undefined + }; + manager.addReporter(captureReporter); + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'bootstrap output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined }, + manager + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush', packageVersion: '5.178.1' }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: initialized.logArtifact?.path, + format: 'plaintext', + complete: false + } + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await initialized.closeAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(await fs.promises.readFile(initialized.logArtifact!.path!, 'utf8')).toContain( + 'bootstrap output\n' + ); + const finalArtifact: IReporterEventEnvelope = reported + .filter(({ type }) => type === 'artifactAvailable') + .at(-1)!; + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + finalArtifact.payload as object, + 'complete' + ); + expect(descriptor).toMatchObject({ value: true, writable: false }); + expect(typeof (finalArtifact.payload as { complete: unknown }).complete).toBe('boolean'); + expect(finalArtifact.source).toEqual({ + packageName: '@microsoft/rush', + packageVersion: '5.178.1' + }); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('replays and deletes a bootstrap handoff before returning the authoritative host', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: { rushVersion: '5.178.1' } }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.host.manager.flushAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(JSON.parse(stdoutText).type).toBe('sessionStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not replay live bootstrap output to the same visible destination', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: [ + 'build', + '--reporter=plaintext', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'old-engine-session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.0' }, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }); + await initialized.host.manager.closeAsync(); + + expect(stdoutText).toBe(''); + expect( + (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }), + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }) + ]); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('retains bootstrap stdout and stderr records in the primary JSON stream', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'captured stdout\n' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--log-level=debug'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect( + stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line).payload) + ).toEqual([ + { stream: 'stdout', text: 'captured stdout\n' }, + { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + ]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'activityChanged', payload: { text: 'installing Rush' } }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace(/"major":1/g, '"major":2')); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('installing Rush\nnpm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('falls back when repository opt-in meets an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('fails an explicit reporter request for an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stderrText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/unsupported required event/); + + expect(stderrText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('deletes an authenticated handoff when explicit reporter validation fails', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/requires an interactive TTY/); + + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json new file mode 100644 index 00000000000..596ca68ca76 --- /dev/null +++ b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "useRushReporter": true +} diff --git a/apps/rush/src/test/sandbox/reporter-demo/README.md b/apps/rush/src/test/sandbox/reporter-demo/README.md new file mode 100644 index 00000000000..e61f310ca47 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -0,0 +1,74 @@ +# Direct Rush reporter demo + +For the complete control, compatibility, privacy, and troubleshooting reference, see the +[experimental Rush reporter guide](../../../../../../docs/rush/reporter.md). + +From a clean checkout, install dependencies, build the reporter path, and run the self-checking direct +invocation demo: + +```sh +node common/scripts/install-run-rush.js install +node common/scripts/install-run-rush.js build --to @microsoft/rush +node apps/rush/src/test/sandbox/reporter-demo/run.mjs +``` + +The script runs the same `rush build --only @rushstack/rush-reporter` operation stream through legacy, +plaintext, JSON, AI, file, and quiet modes, plus parser failure, help, and command-specific JSON cases. +It verifies payload-only machine stdout, one visible writer, ordered/lossless plaintext grouping from a +same-invocation JSON sidecar, final artifact completeness, owner-only log permissions, failure flushing, +AI parser-error context, command-JSON ownership, exclusive sidecar destinations, and the +`RUSH_REPORTER=legacy` rollback transcript. Non-phased `rush list` output must also remain structured in +JSON mode and reach the full-detail log without leaking onto stdout in file mode. +Inherited `RUSH_REPORTER`, `RUSH_LOG_LEVEL`, and +`RUSH_QUIET_MODE` values are removed from the self-check matrix. The matrix sets `RUSH_PREVIEW_VERSION` +to the locally built Rush package version so it exercises the integrated frontend and engine even when +`rush.json` pins an older release; preview warnings remain on stderr. It also verifies CI plaintext output, +cache-path output, a matching `RUSH_PREVIEW_VERSION`, normalized `RUSH_TEMP_FOLDER` log placement, and +matching purge-path selection. Captured stdout/stderr files are written to a temporary folder. + +The final matrix case invokes `rush purge` with an isolated `RUSH_TEMP_FOLDER`. This also unlinks project +dependencies, so run the install command again before continuing development in the checkout. + +For an individual invocation: + +```sh +# Interactive TTY only +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=default + +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json --log-level=debug +RUSH_PREVIEW_VERSION=$(node -p "require('./apps/rush/package.json').version") node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=ai +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=file +RUSH_TEMP_FOLDER=./common/temp/reporter-demo-override node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=file +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext --log-level=quiet +RUSH_REPORTER=legacy node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json +node apps/rush/bin/rush list --json --reporter=file +``` + +Repositories can opt in without a command-line flag by setting `"useRushReporter": true` in +`common/config/rush/experiments.json`. Remove that setting or use `RUSH_REPORTER=legacy` for immediate +rollback. + +The expected output is shape-based: + +- legacy keeps the existing Rush banner, operation blocks, and final status sections; +- `default` uses a width-aware three-row live region and leaves a short final summary; +- explicit `plaintext` groups ordered output under `project (phase)` and prints the absolute full-log path; +- JSON stdout contains only NDJSON event envelopes; +- a matching preview version keeps JSON stdout parseable and writes its warning to stderr; +- AI stdout contains `ai.status` and bounded `ai.final` records; +- file mode leaves stdout empty and writes the full-log path to stderr; +- `RUSH_TEMP_FOLDER` moves the full log and the matching purge removes that override; +- quiet mode retains only the final result and full-log path. + +The intentional missing-project AI failure preserves an actionable diagnostic and complete log reference. +Structured remediation is included when the producing diagnostic supplies it; this parser failure does not +currently provide a remediation action. + +After the demo exits, inspect the latest invocation log with: + +```sh +ls -lt common/temp/rush-logs +sed -n '1,120p' common/temp/rush-logs/latest.log +``` diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs new file mode 100644 index 00000000000..33e58c661f8 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -0,0 +1,313 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptFolder = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptFolder, '..', '..', '..', '..', '..', '..'); +const rushBin = path.join(repoRoot, 'apps', 'rush', 'bin', 'rush'); +const rushVersion = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'apps', 'rush', 'package.json'), 'utf8') +).version; +const outputFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-reporter-demo-')); +const commonArgs = ['build', '--only', '@rushstack/rush-reporter']; +// Exercise the built frontend and engine even when rush.json pins an older release. +const baseEnv = { ...process.env, RUSH_PREVIEW_VERSION: rushVersion }; +delete baseEnv.RUSH_REPORTER; +delete baseEnv.RUSH_LOG_LEVEL; +delete baseEnv.RUSH_QUIET_MODE; + +function run(name, args, env = {}, expectedStatus = 0) { + const result = spawnSync(process.execPath, [rushBin, ...args], { + cwd: repoRoot, + env: { ...baseEnv, ...env }, + encoding: 'utf8' + }); + fs.writeFileSync(path.join(outputFolder, `${name}.stdout`), result.stdout); + fs.writeFileSync(path.join(outputFolder, `${name}.stderr`), result.stderr); + if (result.status !== expectedStatus) { + throw new Error( + `${name} exited with ${result.status}; expected ${expectedStatus}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + return result; +} + +run('warmup', commonArgs); +const legacy = run('legacy', commonArgs).stdout; +const rollback = run('rollback', [...commonArgs, '--reporter=json'], { RUSH_REPORTER: 'legacy' }).stdout; +const normalizeDurations = (text) => text.replace(/\d+\.\d+ seconds/g, '