From 72e089bbbc03149dd74deb27e69a8fc29977726c Mon Sep 17 00:00:00 2001 From: Momo Kornher Date: Fri, 17 Jan 2025 23:50:37 +0000 Subject: [PATCH] refactor(cli): `CliIoHost` is more self contained (#32993) ### Description of changes We currently have to maintain a global singleton `CliIoHost` until we have passed the ioHost through all the layers for logging. Previously the global settings for this `IoHost` were all over the place using setter functions and global variables. This refactor unifies all these APIs on the `CliIoHost`, through the global instance. We also need the ability to register a _different_ `IoHost` that must be used for reporting. This is the case when a Toolkit integrator provides a custom implemenation. ### Describe any new or updated permissions being added no ### Description of how you validated changes Existing and updated test cases. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/toolkit/lib/toolkit/toolkit.ts | 17 +- .../cloudformation/stack-activity-monitor.ts | 12 +- packages/aws-cdk/lib/cli.ts | 45 ++-- packages/aws-cdk/lib/logging.ts | 56 ++--- packages/aws-cdk/lib/toolkit/cli-io-host.ts | 229 ++++++++++++++---- packages/aws-cdk/lib/util/yargs-helpers.ts | 10 +- packages/aws-cdk/test/api/bootstrap2.test.ts | 2 + .../aws-cdk/test/api/deploy-stack.test.ts | 6 +- packages/aws-cdk/test/api/exec.test.ts | 6 +- .../aws-cdk/test/api/logs/logging.test.ts | 29 ++- .../test/api/logs/logs-monitor.test.ts | 2 + .../aws-cdk/test/api/sdk-provider.test.ts | 5 +- .../test/api/stack-activity-monitor.test.ts | 2 + packages/aws-cdk/test/cdk-toolkit.test.ts | 2 + packages/aws-cdk/test/cli.test.ts | 19 +- .../aws-cdk/test/toolkit/cli-io-host.test.ts | 58 ++--- 16 files changed, 315 insertions(+), 185 deletions(-) diff --git a/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts index 061298d027974..b74b519065fd5 100644 --- a/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts @@ -33,7 +33,13 @@ export type ToolkitAction = | 'deploy' | 'rollback' | 'watch' -| 'destroy'; +| 'destroy' +| 'doctor' +| 'gc' +| 'import' +| 'metadata' +| 'init' +| 'migrate'; export interface ToolkitOptions { /** @@ -78,9 +84,14 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab public constructor(private readonly props: ToolkitOptions = {}) { super(); - - this.ioHost = props.ioHost ?? CliIoHost.getIoHost(); this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; + + // Hacky way to re-use the global IoHost until we have fully removed the need for it + const globalIoHost = CliIoHost.instance(); + if (props.ioHost) { + globalIoHost.registerIoHost(props.ioHost as any); + } + this.ioHost = globalIoHost as IIoHost; } public async dispose(): Promise { diff --git a/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts b/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts index 999ad18ed149b..de46599ce0c0a 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts @@ -3,8 +3,8 @@ import { ArtifactMetadataEntryType, type MetadataEntry } from '@aws-cdk/cloud-as import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import { ResourceEvent, StackEventPoller } from './stack-event-poller'; -import { error, setIoMessageThreshold, info } from '../../../logging'; -import { IoMessageLevel } from '../../../toolkit/cli-io-host'; +import { error, info } from '../../../logging'; +import { CliIoHost, IoMessageLevel } from '../../../toolkit/cli-io-host'; import type { ICloudFormationClient } from '../../aws-auth'; import { RewritableBlock } from '../display'; @@ -623,12 +623,13 @@ export class CurrentActivityPrinter extends ActivityPrinterBase { */ public readonly updateSleep: number = 2_000; - private oldLogThreshold: IoMessageLevel = 'info'; + private oldLogThreshold: IoMessageLevel; private readonly stream: NodeJS.WriteStream; private block: RewritableBlock; constructor(props: PrinterProps) { super(props); + this.oldLogThreshold = CliIoHost.instance().logLevel; this.stream = props.stream; this.block = new RewritableBlock(this.stream); } @@ -674,11 +675,12 @@ export class CurrentActivityPrinter extends ActivityPrinterBase { public start() { // Need to prevent the waiter from printing 'stack not stable' every 5 seconds, it messes // with the output calculations. - setIoMessageThreshold('info'); + this.oldLogThreshold = CliIoHost.instance().logLevel; + CliIoHost.instance().logLevel = 'info'; } public stop() { - setIoMessageThreshold(this.oldLogThreshold); + CliIoHost.instance().logLevel = this.oldLogThreshold; // Print failures at the end const lines = new Array(); diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index b1a40738fc819..f1e190a6e0f1e 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -23,7 +23,7 @@ import { docs } from '../lib/commands/docs'; import { doctor } from '../lib/commands/doctor'; import { getMigrateScanType } from '../lib/commands/migrate'; import { cliInit, printAvailableTemplates } from '../lib/init'; -import { data, debug, error, info, setCI, setIoMessageThreshold } from '../lib/logging'; +import { data, debug, error, info } from '../lib/logging'; import { Notices } from '../lib/notices'; import { Command, Configuration, Settings } from '../lib/settings'; import * as version from '../lib/version'; @@ -40,11 +40,12 @@ if (!process.stdout.isTTY) { export async function exec(args: string[], synthesizer?: Synthesizer): Promise { const argv = await parseCommandLineArguments(args); + const cmd = argv._[0]; // if one -v, log at a DEBUG level // if 2 -v, log at a TRACE level + let ioMessageLevel: IoMessageLevel = 'info'; if (argv.verbose) { - let ioMessageLevel: IoMessageLevel; switch (argv.verbose) { case 1: ioMessageLevel = 'debug'; @@ -54,18 +55,20 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise 2) { enableTracing(true); } - if (argv.ci) { - setCI(true); - } - try { await checkForPlatformWarnings(); } catch (e) { @@ -83,8 +86,6 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { - CliIoHost.currentAction = command as any; + ioHost.currentAction = command as any; const toolkitStackName: string = ToolkitInfo.determineName(configuration.settings.get(['toolkitStackName'])); debug(`Toolkit stack: ${chalk.bold(toolkitStackName)}`); @@ -205,6 +206,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise[] = []; -const levelPriority: Record = { - error: 0, - warn: 1, - info: 2, - debug: 3, - trace: 4, -}; - -let currentIoMessageThreshold: IoMessageLevel = 'info'; - -/** - * Sets the current threshold. Messages with a lower priority level will be ignored. - * @param level The new log level threshold - */ -export function setIoMessageThreshold(level: IoMessageLevel) { - currentIoMessageThreshold = level; -} - -/** - * Sets whether the logger is running in CI mode. - * In CI mode, all non-error output goes to stdout instead of stderr. - * @param newCI - Whether CI mode should be enabled - */ -export function setCI(newCI: boolean) { - CliIoHost.ci = newCI; -} - /** * Executes a block of code with corked logging. All log messages during execution * are buffered and only written when all nested cork blocks complete (when CORK_COUNTER reaches 0). @@ -48,14 +21,14 @@ export async function withCorkedLogging(block: () => Promise): Promise if (CORK_COUNTER === 0) { // Process each buffered message through notify for (const ioMessage of logBuffer) { - void CliIoHost.getIoHost().notify(ioMessage); + void CliIoHost.instance().notify(ioMessage); } logBuffer.splice(0); } } } -interface LogOptions { +interface LogMessage { /** * The log level to use */ @@ -79,28 +52,27 @@ interface LogOptions { /** * Internal core logging function that writes messages through the CLI IO host. - * @param options Configuration options for the log message. See {@link LogOptions} + * @param msg Configuration options for the log message. See {@link LogMessage} */ -function log(options: LogOptions) { - if (levelPriority[options.level] > levelPriority[currentIoMessageThreshold]) { - return; - } - +function log(msg: LogMessage) { const ioMessage: IoMessage = { - level: options.level, - message: options.message, - forceStdout: options.forceStdout, + level: msg.level, + message: msg.message, + forceStdout: msg.forceStdout, time: new Date(), - action: CliIoHost.currentAction, - code: options.code, + action: CliIoHost.instance().currentAction, + code: msg.code, }; if (CORK_COUNTER > 0) { + if (levelPriority[msg.level] > levelPriority[CliIoHost.instance().logLevel]) { + return; + } logBuffer.push(ioMessage); return; } - void CliIoHost.getIoHost().notify(ioMessage); + void CliIoHost.instance().notify(ioMessage); } /** diff --git a/packages/aws-cdk/lib/toolkit/cli-io-host.ts b/packages/aws-cdk/lib/toolkit/cli-io-host.ts index d55c394a9efc6..fd7a3be09e46a 100644 --- a/packages/aws-cdk/lib/toolkit/cli-io-host.ts +++ b/packages/aws-cdk/lib/toolkit/cli-io-host.ts @@ -71,6 +71,14 @@ export interface IoRequest extends IoMessage { export type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; +export const levelPriority: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, + trace: 4, +}; + /** * The current action being performed by the CLI. 'none' represents the absence of an action. */ @@ -82,99 +90,190 @@ export type ToolkitAction = | 'deploy' | 'rollback' | 'watch' -| 'destroy'; +| 'destroy' +| 'context' +| 'docs' +| 'doctor' +| 'gc' +| 'import' +| 'metadata' +| 'notices' +| 'init' +| 'migrate' +| 'version'; + +export interface IIoHost { + /** + * Notifies the host of a message. + * The caller waits until the notification completes. + */ + notify(msg: IoMessage): Promise; + + /** + * Notifies the host of a message that requires a response. + * + * If the host does not return a response the suggested + * default response from the input message will be used. + */ + requestResponse(msg: IoRequest): Promise; +} + +export interface CliIoHostProps { + /** + * The initial Toolkit action the hosts starts with. + * + * @default 'none' + */ + readonly currentAction?: ToolkitAction; + + /** + * Determines the verbosity of the output. + * + * The CliIoHost will still receive all messages and requests, + * but only the messages included in this level will be printed. + * + * @default 'info' + */ + readonly logLevel?: IoMessageLevel; + + /** + * Overrides the automatic TTY detection. + * + * When TTY is disabled, the CLI will have no interactions or color. + * + * @default - determined from the current process + */ + readonly isTTY?: boolean; + + /** + * Whether the CliIoHost is running in CI mode. + * + * In CI mode, all non-error output goes to stdout instead of stderr. + * Set to false in the CliIoHost constructor it will be overwritten if the CLI CI argument is passed + * + * @default - determined from the environment, specifically based on `process.env.CI` + */ + readonly isCI?: boolean; +} /** * A simple IO host for the CLI that writes messages to the console. */ -export class CliIoHost { +export class CliIoHost implements IIoHost { /** * Returns the singleton instance */ - static getIoHost(): CliIoHost { - if (!CliIoHost.instance) { - CliIoHost.instance = new CliIoHost(); + static instance(props: CliIoHostProps = {}, forceNew = false): CliIoHost { + if (forceNew || !CliIoHost._instance) { + CliIoHost._instance = new CliIoHost(props); } - return CliIoHost.instance; + return CliIoHost._instance; } /** * Singleton instance of the CliIoHost */ - private static instance: CliIoHost | undefined; + private static _instance: CliIoHost | undefined; + + private _currentAction: ToolkitAction; + private _isCI: boolean; + private _isTTY: boolean; + private _logLevel: IoMessageLevel; + private _internalIoHost?: IIoHost; + + private constructor(props: CliIoHostProps = {}) { + this._currentAction = props.currentAction ?? 'none' as ToolkitAction; + this._isTTY = props.isTTY ?? process.stdout.isTTY ?? false; + this._logLevel = props.logLevel ?? 'info'; + this._isCI = props.isCI ?? isCI(); + } /** - * Determines which output stream to use based on log level and configuration. + * Returns the singleton instance */ - private static getStream(level: IoMessageLevel, forceStdout: boolean) { - // For legacy purposes all log streams are written to stderr by default, unless - // specified otherwise, by passing `forceStdout`, which is used by the `data()` logging function, or - // if the CDK is running in a CI environment. This is because some CI environments will immediately - // fail if stderr is written to. In these cases, we detect if we are in a CI environment and - // write all messages to stdout instead. - if (forceStdout) { - return process.stdout; + public registerIoHost(ioHost: IIoHost) { + if (ioHost !== this) { + this._internalIoHost = ioHost; } - if (level == 'error') return process.stderr; - return this.ci ? process.stdout : process.stderr; } /** - * Whether the host should apply chalk styles to messages. Defaults to false if the host is not running in a TTY. - * - * @default false + * The current action being performed by the CLI. */ - private isTTY: boolean; + public get currentAction(): ToolkitAction { + return this._currentAction; + } /** - * Whether the CliIoHost is running in CI mode. In CI mode, all non-error output goes to stdout instead of stderr. + * Sets the current action being performed by the CLI. * - * Set to false in the CliIoHost constructor it will be overwritten if the CLI CI argument is passed + * @param action The action being performed by the CLI. */ - private ci: boolean; + public set currentAction(action: ToolkitAction) { + this._currentAction = action; + } /** - * the current {@link ToolkitAction} set by the CLI. + * Whether the host can use interactions and message styling. */ - private currentAction: ToolkitAction = 'synth'; - - private constructor() { - this.isTTY = process.stdout.isTTY ?? false; - this.ci = false; - } - - public static get currentAction(): ToolkitAction { - return CliIoHost.getIoHost().currentAction; + public get isTTY(): boolean { + return this._isTTY; } - public static set currentAction(action: ToolkitAction) { - CliIoHost.getIoHost().currentAction = action; + /** + * Set TTY mode, i.e can the host use interactions and message styling. + * + * @param value set TTY mode + */ + public set isTTY(value: boolean) { + this._isTTY = value; } - public static get ci(): boolean { - return CliIoHost.getIoHost().ci; + /** + * Whether the CliIoHost is running in CI mode. In CI mode, all non-error output goes to stdout instead of stderr. + */ + public get isCI(): boolean { + return this._isCI; } - public static set ci(value: boolean) { - CliIoHost.getIoHost().ci = value; + /** + * Set the CI mode. In CI mode, all non-error output goes to stdout instead of stderr. + * @param value set the CI mode + */ + public set isCI(value: boolean) { + this._isCI = value; } - public static get isTTY(): boolean { - return CliIoHost.getIoHost().isTTY; + /** + * The current threshold. Messages with a lower priority level will be ignored. + */ + public get logLevel(): IoMessageLevel { + return this._logLevel; } - public static set isTTY(value: boolean) { - CliIoHost.getIoHost().isTTY = value; + /** + * Sets the current threshold. Messages with a lower priority level will be ignored. + * @param level The new log level threshold + */ + public set logLevel(level: IoMessageLevel) { + this._logLevel = level; } /** * Notifies the host of a message. * The caller waits until the notification completes. */ - async notify(msg: IoMessage): Promise { - const output = this.formatMessage(msg); + public async notify(msg: IoMessage): Promise { + if (this._internalIoHost) { + return this._internalIoHost.notify(msg); + } - const stream = CliIoHost.getStream(msg.level, msg.forceStdout ?? false); + if (levelPriority[msg.level] > levelPriority[this.logLevel]) { + return; + } + + const output = this.formatMessage(msg); + const stream = this.stream(msg.level, msg.forceStdout ?? false); return new Promise((resolve, reject) => { stream.write(output, (err) => { @@ -187,13 +286,33 @@ export class CliIoHost { }); } + /** + * Determines which output stream to use based on log level and configuration. + */ + private stream(level: IoMessageLevel, forceStdout: boolean) { + // For legacy purposes all log streams are written to stderr by default, unless + // specified otherwise, by passing `forceStdout`, which is used by the `data()` logging function, or + // if the CDK is running in a CI environment. This is because some CI environments will immediately + // fail if stderr is written to. In these cases, we detect if we are in a CI environment and + // write all messages to stdout instead. + if (forceStdout) { + return process.stdout; + } + if (level == 'error') return process.stderr; + return CliIoHost.instance().isCI ? process.stdout : process.stderr; + } + /** * Notifies the host of a message that requires a response. * * If the host does not return a response the suggested * default response from the input message will be used. */ - async requestResponse(msg: IoRequest): Promise { + public async requestResponse(msg: IoRequest): Promise { + if (this._internalIoHost) { + return this._internalIoHost.requestResponse(msg); + } + await this.notify(msg); return msg.defaultResponse; } @@ -203,7 +322,7 @@ export class CliIoHost { */ private formatMessage(msg: IoMessage): string { // apply provided style or a default style if we're in TTY mode - let message_text = this.isTTY + let message_text = this._isTTY ? styleMap[msg.level](msg.message) : msg.message; @@ -222,10 +341,18 @@ export class CliIoHost { } } -export const styleMap: Record string> = { +const styleMap: Record string> = { error: chalk.red, warn: chalk.yellow, info: chalk.white, debug: chalk.gray, trace: chalk.gray, }; + +/** + * Returns true if the current process is running in a CI environment + * @returns true if the current process is running in a CI environment + */ +export function isCI(): boolean { + return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0'; +} diff --git a/packages/aws-cdk/lib/util/yargs-helpers.ts b/packages/aws-cdk/lib/util/yargs-helpers.ts index 884f1cf74d97a..72c315fc19488 100644 --- a/packages/aws-cdk/lib/util/yargs-helpers.ts +++ b/packages/aws-cdk/lib/util/yargs-helpers.ts @@ -1,5 +1,7 @@ import * as version from '../../lib/version'; +export { isCI } from '../toolkit/cli-io-host'; + /** * yargs middleware to negate an option if a negative alias is provided * E.g. `-R` will imply `--rollback=false` @@ -22,14 +24,6 @@ export function yargsNegativeAlias { + CliIoHost.instance().isCI = false; bootstrapper = new Bootstrapper({ source: 'default' }); stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation(() => { return true; diff --git a/packages/aws-cdk/test/api/deploy-stack.test.ts b/packages/aws-cdk/test/api/deploy-stack.test.ts index 161cfceab1c4d..99f81ff77d32f 100644 --- a/packages/aws-cdk/test/api/deploy-stack.test.ts +++ b/packages/aws-cdk/test/api/deploy-stack.test.ts @@ -20,7 +20,7 @@ import { assertIsSuccessfulDeployStackResult, deployStack, DeployStackOptions } import { NoBootstrapStackEnvironmentResources } from '../../lib/api/environment-resources'; import { HotswapMode } from '../../lib/api/hotswap/common'; import { tryHotswapDeployment } from '../../lib/api/hotswap-deployments'; -import { setCI } from '../../lib/logging'; +import { CliIoHost } from '../../lib/toolkit/cli-io-host'; import { DEFAULT_FAKE_TEMPLATE, testStack } from '../util'; import { mockCloudFormationClient, @@ -386,7 +386,7 @@ describe('ci=true', () => { let stderrMock: jest.SpyInstance; let stdoutMock: jest.SpyInstance; beforeEach(() => { - setCI(true); + CliIoHost.instance().isCI = true; jest.resetAllMocks(); stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation(() => { return true; @@ -396,7 +396,7 @@ describe('ci=true', () => { }); }); afterEach(() => { - setCI(false); + CliIoHost.instance().isCI = false; }); test('output written to stdout', async () => { // GIVEN diff --git a/packages/aws-cdk/test/api/exec.test.ts b/packages/aws-cdk/test/api/exec.test.ts index fa450d241a6ae..dfbb8920125e8 100644 --- a/packages/aws-cdk/test/api/exec.test.ts +++ b/packages/aws-cdk/test/api/exec.test.ts @@ -7,18 +7,18 @@ import * as semver from 'semver'; import * as sinon from 'sinon'; import { ImportMock } from 'ts-mock-imports'; import { execProgram } from '../../lib/api/cxapp/exec'; -import { setIoMessageThreshold } from '../../lib/logging'; import { Configuration } from '../../lib/settings'; import { testAssembly } from '../util'; import { mockSpawn } from '../util/mock-child_process'; import { MockSdkProvider } from '../util/mock-sdk'; import { RWLock } from '../../lib/api/util/rwlock'; import { rewriteManifestVersion } from './assembly-versions'; +import { CliIoHost } from '../../lib/toolkit/cli-io-host'; let sdkProvider: MockSdkProvider; let config: Configuration; beforeEach(() => { - setIoMessageThreshold('debug'); + CliIoHost.instance().logLevel = 'debug'; sdkProvider = new MockSdkProvider(); config = new Configuration(); @@ -37,7 +37,7 @@ beforeEach(() => { }); afterEach(() => { - setIoMessageThreshold('info'); + CliIoHost.instance().logLevel = 'info'; sinon.restore(); bockfs.restore(); diff --git a/packages/aws-cdk/test/api/logs/logging.test.ts b/packages/aws-cdk/test/api/logs/logging.test.ts index b7ec1db6df603..9ea45a3505258 100644 --- a/packages/aws-cdk/test/api/logs/logging.test.ts +++ b/packages/aws-cdk/test/api/logs/logging.test.ts @@ -1,6 +1,8 @@ -import { setIoMessageThreshold, setCI, data, success, highlight, error, warning, info, debug, trace, withCorkedLogging } from '../../../lib/logging'; +import { data, success, highlight, error, warning, info, debug, trace, withCorkedLogging } from '../../../lib/logging'; +import { CliIoHost } from '../../../lib/toolkit/cli-io-host'; describe('logging', () => { + const ioHost = CliIoHost.instance({}, true); let mockStdout: jest.Mock; let mockStderr: jest.Mock; @@ -10,8 +12,8 @@ describe('logging', () => { }; beforeEach(() => { - setIoMessageThreshold('info'); - setCI(false); + ioHost.logLevel = 'info'; + ioHost.isCI = false; mockStdout = jest.fn(); mockStderr = jest.fn(); @@ -66,7 +68,7 @@ describe('logging', () => { }); test('info() writes to stdout in CI mode with both styles', () => { - setCI(true); + ioHost.isCI = true; // String style info('test print'); expect(mockStdout).toHaveBeenCalledWith('test print\n'); @@ -80,7 +82,7 @@ describe('logging', () => { describe('log levels', () => { test('respects log level settings with both styles', () => { - setIoMessageThreshold('error'); + ioHost.logLevel = 'error'; // String style error('error message'); @@ -101,12 +103,12 @@ describe('logging', () => { }); test('debug messages only show at debug level with both styles', () => { - setIoMessageThreshold('info'); + ioHost.logLevel = 'info'; debug('debug message'); debug({ message: 'debug message 2' }); expect(mockStderr).not.toHaveBeenCalled(); - setIoMessageThreshold('debug'); + ioHost.logLevel = 'debug'; debug('debug message'); debug({ message: 'debug message 2' }); expect(mockStderr).toHaveBeenCalledWith( @@ -118,12 +120,12 @@ describe('logging', () => { }); test('trace messages only show at trace level with both styles', () => { - setIoMessageThreshold('debug'); + ioHost.logLevel = 'debug'; trace('trace message'); trace({ message: 'trace message 2' }); expect(mockStderr).not.toHaveBeenCalled(); - setIoMessageThreshold('trace'); + ioHost.logLevel = 'trace'; trace('trace message'); trace({ message: 'trace message 2' }); expect(mockStderr).toHaveBeenCalledWith( @@ -287,7 +289,7 @@ describe('logging', () => { test('maintains correct order with mixed log levels in corked block', async () => { // Set threshold to debug to allow debug messages - setIoMessageThreshold('debug'); + ioHost.logLevel = 'debug'; await withCorkedLogging(async () => { error('error message'); @@ -303,15 +305,12 @@ describe('logging', () => { 'success message\n', expect.stringMatching(/^\[\d{2}:\d{2}:\d{2}\] debug message\n$/), ]); - - // Reset threshold back to info for other tests - setIoMessageThreshold('info'); }); }); describe('CI mode behavior', () => { test('correctly switches between stdout and stderr based on CI mode', () => { - setCI(true); + ioHost.isCI = true; warning('warning in CI'); success('success in CI'); error('error in CI'); @@ -320,7 +319,7 @@ describe('logging', () => { expect(mockStdout).toHaveBeenCalledWith('success in CI\n'); expect(mockStderr).toHaveBeenCalledWith('error in CI\n'); - setCI(false); + ioHost.isCI = false; warning('warning not in CI'); success('success not in CI'); error('error not in CI'); diff --git a/packages/aws-cdk/test/api/logs/logs-monitor.test.ts b/packages/aws-cdk/test/api/logs/logs-monitor.test.ts index 4bdb7f96bd0a4..a6bced66c2d9e 100644 --- a/packages/aws-cdk/test/api/logs/logs-monitor.test.ts +++ b/packages/aws-cdk/test/api/logs/logs-monitor.test.ts @@ -1,5 +1,6 @@ import { FilterLogEventsCommand, type FilteredLogEvent } from '@aws-sdk/client-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from '../../../lib/api/logs/logs-monitor'; +import { CliIoHost } from '../../../lib/toolkit/cli-io-host'; import { sleep } from '../../util'; import { MockSdk, mockCloudWatchClient } from '../../util/mock-sdk'; @@ -13,6 +14,7 @@ let sdk: MockSdk; let stderrMock: jest.SpyInstance; let monitor: CloudWatchLogEventMonitor; beforeEach(() => { + CliIoHost.instance().isCI = false; monitor = new CloudWatchLogEventMonitor(new Date(T100)); stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { // Strip ANSI codes when capturing output diff --git a/packages/aws-cdk/test/api/sdk-provider.test.ts b/packages/aws-cdk/test/api/sdk-provider.test.ts index 50bbb1faeeebd..fedd778f61cfe 100644 --- a/packages/aws-cdk/test/api/sdk-provider.test.ts +++ b/packages/aws-cdk/test/api/sdk-provider.test.ts @@ -10,7 +10,7 @@ import { AwsCliCompatible } from '../../lib/api/aws-auth/awscli-compatible'; import { defaultCliUserAgent } from '../../lib/api/aws-auth/user-agent'; import { PluginHost } from '../../lib/api/plugin'; import { Mode } from '../../lib/api/plugin/mode'; -import { setIoMessageThreshold } from '../../lib/logging'; +import { CliIoHost } from '../../lib/toolkit/cli-io-host'; import { withMocked } from '../util'; import { mockSTSClient, restoreSdkMocksToDefault } from '../util/mock-sdk'; @@ -40,7 +40,7 @@ beforeEach(() => { uid = `(${uuid.v4()})`; pluginQueried = false; - setIoMessageThreshold('trace'); + CliIoHost.instance().logLevel = 'trace'; PluginHost.instance.credentialProviderSources.splice(0); PluginHost.instance.credentialProviderSources.push({ @@ -71,6 +71,7 @@ beforeEach(() => { }); afterEach(() => { + CliIoHost.instance().logLevel = 'info'; bockfs.restore(); jest.restoreAllMocks(); }); diff --git a/packages/aws-cdk/test/api/stack-activity-monitor.test.ts b/packages/aws-cdk/test/api/stack-activity-monitor.test.ts index d4f47d5755868..9bed976830acb 100644 --- a/packages/aws-cdk/test/api/stack-activity-monitor.test.ts +++ b/packages/aws-cdk/test/api/stack-activity-monitor.test.ts @@ -3,6 +3,7 @@ import * as chalk from 'chalk'; import { stderr } from './console-listener'; import { HistoryActivityPrinter } from '../../lib/api/util/cloudformation/stack-activity-monitor'; import { ResourceStatus } from '@aws-sdk/client-cloudformation'; +import { CliIoHost } from '../../lib/toolkit/cli-io-host'; let TIMESTAMP: number; let HUMAN_TIME: string; @@ -10,6 +11,7 @@ let HUMAN_TIME: string; beforeAll(() => { TIMESTAMP = new Date().getTime(); HUMAN_TIME = new Date(TIMESTAMP).toLocaleTimeString(); + CliIoHost.instance().isCI = false; }); test('prints 0/4 progress report, when addActivity is called with an "IN_PROGRESS" ResourceStatus', () => { diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 10c57774a5927..39c0e69c91d10 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -92,6 +92,7 @@ import { CdkToolkit, markTesting } from '../lib/cdk-toolkit'; import { RequireApproval } from '../lib/diff'; import { Configuration } from '../lib/settings'; import { Tag } from '../lib/tags'; +import { CliIoHost } from '../lib/toolkit/cli-io-host'; import { flatten } from '../lib/util'; markTesting(); @@ -124,6 +125,7 @@ beforeEach(() => { ], }); + CliIoHost.instance().isCI = false; stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation(() => { return true; }); diff --git a/packages/aws-cdk/test/cli.test.ts b/packages/aws-cdk/test/cli.test.ts index d4a524c6a7cae..b905de2a4221b 100644 --- a/packages/aws-cdk/test/cli.test.ts +++ b/packages/aws-cdk/test/cli.test.ts @@ -1,16 +1,11 @@ import { exec } from '../lib/cli'; -import { setIoMessageThreshold } from '../lib/logging'; +import { CliIoHost } from '../lib/toolkit/cli-io-host'; // Store original version module exports so we don't conflict with other tests const originalVersion = jest.requireActual('../lib/version'); // Mock the dependencies jest.mock('../lib/logging', () => ({ - LogLevel: { - DEBUG: 'DEBUG', - TRACE: 'TRACE', - }, - setIoMessageThreshold: jest.fn(), debug: jest.fn(), error: jest.fn(), print: jest.fn(), @@ -74,31 +69,31 @@ describe('exec verbose flag tests', () => { test('should not set log level when no verbose flag is present', async () => { await exec(['version']); - expect(setIoMessageThreshold).not.toHaveBeenCalled(); + expect(CliIoHost.instance().logLevel).toBe('info'); }); test('should set DEBUG level with single -v flag', async () => { await exec(['-v', 'version']); - expect(setIoMessageThreshold).toHaveBeenCalledWith('debug'); + expect(CliIoHost.instance().logLevel).toBe('debug'); }); test('should set TRACE level with double -v flag', async () => { await exec(['-v', '-v', 'version']); - expect(setIoMessageThreshold).toHaveBeenCalledWith('trace'); + expect(CliIoHost.instance().logLevel).toBe('trace'); }); test('should set DEBUG level with --verbose=1', async () => { await exec(['--verbose', '1', 'version']); - expect(setIoMessageThreshold).toHaveBeenCalledWith('debug'); + expect(CliIoHost.instance().logLevel).toBe('debug'); }); test('should set TRACE level with --verbose=2', async () => { await exec(['--verbose', '2', 'version']); - expect(setIoMessageThreshold).toHaveBeenCalledWith('trace'); + expect(CliIoHost.instance().logLevel).toBe('trace'); }); test('should set TRACE level with verbose level > 2', async () => { await exec(['--verbose', '3', 'version']); - expect(setIoMessageThreshold).toHaveBeenCalledWith('trace'); + expect(CliIoHost.instance().logLevel).toBe('trace'); }); }); diff --git a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts index 210a9a310a95e..99a2962ff1557 100644 --- a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts +++ b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts @@ -1,6 +1,10 @@ import * as chalk from 'chalk'; import { CliIoHost, IoMessage } from '../../lib/toolkit/cli-io-host'; +const ioHost = CliIoHost.instance({ + logLevel: 'trace', +}); + describe('CliIoHost', () => { let mockStdout: jest.Mock; let mockStderr: jest.Mock; @@ -11,9 +15,9 @@ describe('CliIoHost', () => { mockStderr = jest.fn(); // Reset singleton state - CliIoHost.isTTY = process.stdout.isTTY ?? false; - CliIoHost.ci = false; - CliIoHost.currentAction = 'synth'; + ioHost.isTTY = process.stdout.isTTY ?? false; + ioHost.isCI = false; + ioHost.currentAction = 'synth'; defaultMessage = { time: new Date('2024-01-01T12:00:00'), @@ -44,8 +48,8 @@ describe('CliIoHost', () => { describe('stream selection', () => { test('writes to stderr by default for non-error messages in non-CI mode', async () => { - CliIoHost.isTTY = true; - await CliIoHost.getIoHost().notify({ + ioHost.isTTY = true; + await ioHost.notify({ time: new Date(), level: 'info', action: 'synth', @@ -58,8 +62,8 @@ describe('CliIoHost', () => { }); test('writes to stderr for error level with red color', async () => { - CliIoHost.isTTY = true; - await CliIoHost.getIoHost().notify({ + ioHost.isTTY = true; + await ioHost.notify({ time: new Date(), level: 'error', action: 'synth', @@ -72,8 +76,8 @@ describe('CliIoHost', () => { }); test('writes to stdout when forceStdout is true', async () => { - CliIoHost.isTTY = true; - await CliIoHost.getIoHost().notify({ + ioHost.isTTY = true; + await ioHost.notify({ time: new Date(), level: 'info', action: 'synth', @@ -89,11 +93,11 @@ describe('CliIoHost', () => { describe('message formatting', () => { beforeEach(() => { - CliIoHost.isTTY = true; + ioHost.isTTY = true; }); test('formats debug messages with timestamp', async () => { - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ ...defaultMessage, level: 'debug', forceStdout: true, @@ -103,7 +107,7 @@ describe('CliIoHost', () => { }); test('formats trace messages with timestamp', async () => { - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ ...defaultMessage, level: 'trace', forceStdout: true, @@ -113,8 +117,8 @@ describe('CliIoHost', () => { }); test('applies no styling when TTY is false', async () => { - CliIoHost.isTTY = false; - await CliIoHost.getIoHost().notify({ + ioHost.isTTY = false; + await ioHost.notify({ ...defaultMessage, forceStdout: true, }); @@ -132,7 +136,7 @@ describe('CliIoHost', () => { ]; for (const { level, style } of testCases) { - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ ...defaultMessage, level, forceStdout: true, @@ -150,19 +154,19 @@ describe('CliIoHost', () => { describe('action handling', () => { test('sets and gets current action', () => { - CliIoHost.currentAction = 'deploy'; - expect(CliIoHost.currentAction).toBe('deploy'); + ioHost.currentAction = 'deploy'; + expect(ioHost.currentAction).toBe('deploy'); }); }); describe('CI mode behavior', () => { beforeEach(() => { - CliIoHost.isTTY = true; - CliIoHost.ci = true; + ioHost.isTTY = true; + ioHost.isCI = true; }); test('writes to stdout in CI mode when level is not error', async () => { - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ time: new Date(), level: 'info', action: 'synth', @@ -175,7 +179,7 @@ describe('CliIoHost', () => { }); test('writes to stderr for error level in CI mode', async () => { - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ time: new Date(), level: 'error', action: 'synth', @@ -190,12 +194,12 @@ describe('CliIoHost', () => { describe('timestamp handling', () => { beforeEach(() => { - CliIoHost.isTTY = true; + ioHost.isTTY = true; }); test('includes timestamp for DEBUG level with gray color', async () => { const testDate = new Date('2024-01-01T12:34:56'); - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ time: testDate, level: 'debug', action: 'synth', @@ -209,7 +213,7 @@ describe('CliIoHost', () => { test('excludes timestamp for other levels but includes color', async () => { const testDate = new Date('2024-01-01T12:34:56'); - await CliIoHost.getIoHost().notify({ + await ioHost.notify({ time: testDate, level: 'info', action: 'synth', @@ -229,7 +233,7 @@ describe('CliIoHost', () => { return true; }); - await expect(CliIoHost.getIoHost().notify({ + await expect(ioHost.notify({ time: new Date(), level: 'info', action: 'synth', @@ -242,8 +246,8 @@ describe('CliIoHost', () => { describe('requestResponse', () => { test('logs messages and returns default', async () => { - CliIoHost.isTTY = true; - const response = await CliIoHost.getIoHost().requestResponse({ + ioHost.isTTY = true; + const response = await ioHost.requestResponse({ time: new Date(), level: 'info', action: 'synth',