diff --git a/packages/aws-cdk-lib/testhelpers/jest-bufferedconsole.ts b/packages/aws-cdk-lib/testhelpers/jest-bufferedconsole.ts index c66ededbdaff0..a81ddb4282e57 100644 --- a/packages/aws-cdk-lib/testhelpers/jest-bufferedconsole.ts +++ b/packages/aws-cdk-lib/testhelpers/jest-bufferedconsole.ts @@ -13,7 +13,10 @@ interface ConsoleMessage { export default class TestEnvironment extends NodeEnvironment implements JestEnvironment { private log = new Array(); + private originalConsole!: typeof console; + private originalStdoutWrite!: typeof process.stdout.write; + private originalStderrWrite!: typeof process.stderr.write; constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { super(config, context); @@ -41,6 +44,8 @@ export default class TestEnvironment extends NodeEnvironment implements JestEnvi this.log = []; this.originalConsole = console; + this.originalStdoutWrite = process.stdout.write; + this.originalStderrWrite = process.stderr.write; this.global.console = { ...console, @@ -50,10 +55,30 @@ export default class TestEnvironment extends NodeEnvironment implements JestEnvi info: (message) => this.log.push({ type: 'info', message }), debug: (message) => this.log.push({ type: 'debug', message }), }; + + const self = this; + process.stdout.write = function (chunk: Buffer | string, enccb?: BufferEncoding | ((error?: Error | null) => void)): void { + const encoding = typeof enccb === 'string' ? enccb : 'utf-8'; + const message = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : chunk; + self.log.push({ type: 'log', message: message.replace(/\n$/, '') }); + if (typeof enccb === 'function') { + enccb(); + } + } as any; + process.stderr.write = function (chunk: Buffer | string, enccb?: BufferEncoding | ((error?: Error | null) => void)): void { + const encoding = typeof enccb === 'string' ? enccb : 'utf-8'; + const message = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : chunk; + self.log.push({ type: 'error', message: message.replace(/\n$/, '') }); + if (typeof enccb === 'function') { + enccb(); + } + } as any; } async teardown() { this.global.console = this.originalConsole; + process.stdout.write = this.originalStdoutWrite; + process.stderr.write = this.originalStderrWrite; await super.teardown(); } } @@ -72,4 +97,3 @@ function fullTestName(test: TestDescription) { } return ret; } - diff --git a/packages/aws-cdk/jest.config.js b/packages/aws-cdk/jest.config.js index 23f1a71b38590..03917ee50eb84 100644 --- a/packages/aws-cdk/jest.config.js +++ b/packages/aws-cdk/jest.config.js @@ -31,6 +31,7 @@ const config = { // fail because they rely on shared mutable state left by other tests // (files on disk, global mocks, etc). randomize: true, + testEnvironment: './test/jest-bufferedconsole.ts', }; // Disable coverage running in the VSCode debug terminal: we never want coverage @@ -40,4 +41,4 @@ if (process.env.VSCODE_INJECTION) { config.collectCoverage = false; } -module.exports = config; \ No newline at end of file +module.exports = config; diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index e77fbb90d7ea2..b6cdedea62629 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -92,6 +92,7 @@ "constructs": "^10.0.0", "fast-check": "^3.22.0", "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "jest-mock": "^29.7.0", "madge": "^5.0.2", "make-runnable": "^1.4.1", diff --git a/packages/aws-cdk/test/jest-bufferedconsole.ts b/packages/aws-cdk/test/jest-bufferedconsole.ts new file mode 100644 index 0000000000000..a81ddb4282e57 --- /dev/null +++ b/packages/aws-cdk/test/jest-bufferedconsole.ts @@ -0,0 +1,99 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/** + * A Jest environment that buffers outputs to `console.log()` and only shows it for failing tests. + */ +import type { EnvironmentContext, JestEnvironment, JestEnvironmentConfig } from '@jest/environment'; +import { Circus } from '@jest/types'; +import { TestEnvironment as NodeEnvironment } from 'jest-environment-node'; + +interface ConsoleMessage { + type: 'log' | 'error' | 'warn' | 'info' | 'debug'; + message: string; +} + +export default class TestEnvironment extends NodeEnvironment implements JestEnvironment { + private log = new Array(); + + private originalConsole!: typeof console; + private originalStdoutWrite!: typeof process.stdout.write; + private originalStderrWrite!: typeof process.stderr.write; + + constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { + super(config, context); + + // We need to set the event handler by assignment in the constructor, + // because if we declare it as an async member TypeScript's type derivation + // doesn't work properly. + (this as JestEnvironment).handleTestEvent = (async (event, _state) => { + if (event.name === 'test_done' && event.test.errors.length > 0 && this.log.length > 0) { + this.originalConsole.log(`[Console output] ${fullTestName(event.test)}\n`); + for (const item of this.log) { + this.originalConsole[item.type](' ' + item.message); + } + this.originalConsole.log('\n'); + } + + if (event.name === 'test_done') { + this.log = []; + } + }) satisfies Circus.EventHandler; + } + + async setup() { + await super.setup(); + + this.log = []; + this.originalConsole = console; + this.originalStdoutWrite = process.stdout.write; + this.originalStderrWrite = process.stderr.write; + + this.global.console = { + ...console, + log: (message) => this.log.push({ type: 'log', message }), + error: (message) => this.log.push({ type: 'error', message }), + warn: (message) => this.log.push({ type: 'warn', message }), + info: (message) => this.log.push({ type: 'info', message }), + debug: (message) => this.log.push({ type: 'debug', message }), + }; + + const self = this; + process.stdout.write = function (chunk: Buffer | string, enccb?: BufferEncoding | ((error?: Error | null) => void)): void { + const encoding = typeof enccb === 'string' ? enccb : 'utf-8'; + const message = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : chunk; + self.log.push({ type: 'log', message: message.replace(/\n$/, '') }); + if (typeof enccb === 'function') { + enccb(); + } + } as any; + process.stderr.write = function (chunk: Buffer | string, enccb?: BufferEncoding | ((error?: Error | null) => void)): void { + const encoding = typeof enccb === 'string' ? enccb : 'utf-8'; + const message = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : chunk; + self.log.push({ type: 'error', message: message.replace(/\n$/, '') }); + if (typeof enccb === 'function') { + enccb(); + } + } as any; + } + + async teardown() { + this.global.console = this.originalConsole; + process.stdout.write = this.originalStdoutWrite; + process.stderr.write = this.originalStderrWrite; + await super.teardown(); + } +} + +// DescribeBlock is not exported from `@jest/types`, so we need to build the parts we are interested in +type TestDescription = PartialBy, 'parent'>; + +// Utility type to make specific fields optional +type PartialBy = Omit & Partial> + +function fullTestName(test: TestDescription) { + let ret = test.name; + while (test.parent != null && test.parent.name !== 'ROOT_DESCRIBE_BLOCK') { + ret = test.parent.name + ' › ' + fullTestName; + test = test.parent; + } + return ret; +}