diff --git a/integration/hello-world/e2e/force-console.spec.ts b/integration/hello-world/e2e/force-console.spec.ts new file mode 100644 index 00000000000..cb6aa733baa --- /dev/null +++ b/integration/hello-world/e2e/force-console.spec.ts @@ -0,0 +1,163 @@ +import { ConsoleLogger, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import * as sinon from 'sinon'; +import { expect } from 'chai'; + +describe('ForceConsole Option', () => { + let app: INestApplication; + + describe('When forceConsole is true', () => { + let consoleLogSpy: sinon.SinonSpy; + let consoleErrorSpy: sinon.SinonSpy; + let processStdoutSpy: sinon.SinonSpy; + let processStderrSpy: sinon.SinonSpy; + + beforeEach(async () => { + // Spy on console and process methods + consoleLogSpy = sinon.spy(console, 'log'); + consoleErrorSpy = sinon.spy(console, 'error'); + processStdoutSpy = sinon.spy(process.stdout, 'write'); + processStderrSpy = sinon.spy(process.stderr, 'write'); + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication({ + forceConsole: true, + logger: ['log', 'error'], + }); + + await app.init(); + }); + + afterEach(async () => { + consoleLogSpy.restore(); + consoleErrorSpy.restore(); + processStdoutSpy.restore(); + processStderrSpy.restore(); + await app.close(); + }); + + it('should use console.log instead of process.stdout.write', async () => { + const logger = new ConsoleLogger('TestContext', { forceConsole: true }); + logger.log('Test log message'); + + // Should use console.log when forceConsole is true + expect(consoleLogSpy.called).to.be.true; + // Verify console.log was called with the message + const consoleLogCalls = consoleLogSpy + .getCalls() + .filter(call => + call.args.some(arg => String(arg).includes('Test log message')), + ); + expect(consoleLogCalls.length).to.be.greaterThan(0); + }); + + it('should use console.error instead of process.stderr.write', async () => { + const logger = new ConsoleLogger('TestContext', { forceConsole: true }); + logger.error('Test error message'); + + // Should use console.error when forceConsole is true + expect(consoleErrorSpy.called).to.be.true; + // Verify console.error was called with the message + const consoleErrorCalls = consoleErrorSpy + .getCalls() + .filter(call => + call.args.some(arg => String(arg).includes('Test error message')), + ); + expect(consoleErrorCalls.length).to.be.greaterThan(0); + }); + + it('should handle GET request with forceConsole option enabled', () => { + return request(app.getHttpServer()).get('/hello').expect(200); + }); + }); + + describe('When forceConsole is false (default)', () => { + let consoleLogSpy: sinon.SinonSpy; + let consoleErrorSpy: sinon.SinonSpy; + let processStdoutSpy: sinon.SinonSpy; + let processStderrSpy: sinon.SinonSpy; + + beforeEach(async () => { + // Spy on console and process methods + consoleLogSpy = sinon.spy(console, 'log'); + consoleErrorSpy = sinon.spy(console, 'error'); + processStdoutSpy = sinon.spy(process.stdout, 'write'); + processStderrSpy = sinon.spy(process.stderr, 'write'); + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication({ + logger: ['log', 'error'], + // forceConsole is not set, defaults to false + }); + + await app.init(); + }); + + afterEach(async () => { + consoleLogSpy.restore(); + consoleErrorSpy.restore(); + processStdoutSpy.restore(); + processStderrSpy.restore(); + await app.close(); + }); + + it('should not directly call console.log when forceConsole is false', async () => { + const logger = new ConsoleLogger('TestContext'); + + // Reset spy to ensure clean state + consoleLogSpy.resetHistory(); + + logger.log('Test log message'); + + // When forceConsole is false, should not call console.log + expect(consoleLogSpy.called).to.be.false; + }); + + it('should not directly call console.error when forceConsole is false', async () => { + const logger = new ConsoleLogger('TestContext'); + + // Reset spy to ensure clean state + consoleErrorSpy.resetHistory(); + + logger.error('Test error message'); + + // When forceConsole is false, should not call console.error + expect(consoleErrorSpy.called).to.be.false; + }); + }); + + describe('When forceConsole is set via NestFactory.create', () => { + it('should apply forceConsole to the default logger', async () => { + const consoleLogSpy = sinon.spy(console, 'log'); + const processStdoutSpy = sinon.spy(process.stdout, 'write'); + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const testApp = moduleRef.createNestApplication({ + forceConsole: true, + }); + + await testApp.init(); + + // The logger created by NestFactory should respect forceConsole option + const logger = new ConsoleLogger('AppContext', { forceConsole: true }); + logger.log('Application started'); + + expect(consoleLogSpy.called).to.be.true; + + consoleLogSpy.restore(); + processStdoutSpy.restore(); + await testApp.close(); + }); + }); +}); diff --git a/packages/common/interfaces/nest-application-context-options.interface.ts b/packages/common/interfaces/nest-application-context-options.interface.ts index 63a9b793a8a..98aad7391af 100644 --- a/packages/common/interfaces/nest-application-context-options.interface.ts +++ b/packages/common/interfaces/nest-application-context-options.interface.ts @@ -67,4 +67,11 @@ export class NestApplicationContextOptions { */ instanceDecorator: (instance: unknown) => unknown; }; + + /** + * If enabled, will force the use of console.log/console.error instead of process.stdout/stderr.write + * in the default ConsoleLogger. This is useful for test environments like Jest that can buffer console calls. + * @default false + */ + forceConsole?: boolean; } diff --git a/packages/common/services/console-logger.service.ts b/packages/common/services/console-logger.service.ts index ca86200f901..9c0fb7645a5 100644 --- a/packages/common/services/console-logger.service.ts +++ b/packages/common/services/console-logger.service.ts @@ -43,6 +43,12 @@ export interface ConsoleLoggerOptions { * The context of the logger. */ context?: string; + /** + * If enabled, will force the use of console.log/console.error instead of process.stdout/stderr.write. + * This is useful for test environments like Jest that can buffer console calls. + * @default false + */ + forceConsole?: boolean; /** * If enabled, will print the log message in a single line, even if it is an object with multiple properties. * If set to a number, the most n inner elements are united on a single line as long as all properties fit into breakLength. Short array elements are also grouped together. @@ -334,7 +340,15 @@ export class ConsoleLogger implements LoggerService { timestampDiff, ); - process[writeStreamType ?? 'stdout'].write(formattedMessage); + if (this.options.forceConsole) { + if (writeStreamType === 'stderr') { + console.error(formattedMessage.trim()); + } else { + console.log(formattedMessage.trim()); + } + } else { + process[writeStreamType ?? 'stdout'].write(formattedMessage); + } }); } @@ -352,7 +366,17 @@ export class ConsoleLogger implements LoggerService { !this.options.colors && this.inspectOptions.compact === true ? JSON.stringify(logObject, this.stringifyReplacer) : inspect(logObject, this.inspectOptions); - process[options.writeStreamType ?? 'stdout'].write(`${formattedMessage}\n`); + if (this.options.forceConsole) { + if (options.writeStreamType === 'stderr') { + console.error(formattedMessage); + } else { + console.log(formattedMessage); + } + } else { + process[options.writeStreamType ?? 'stdout'].write( + `${formattedMessage}\n`, + ); + } } protected getJsonLogObject( @@ -455,7 +479,11 @@ export class ConsoleLogger implements LoggerService { if (!stack || this.options.json) { return; } - process.stderr.write(`${stack}\n`); + if (this.options.forceConsole) { + console.error(stack); + } else { + process.stderr.write(`${stack}\n`); + } } protected updateAndGetTimestampDiff(): string { diff --git a/packages/common/test/services/logger.service.spec.ts b/packages/common/test/services/logger.service.spec.ts index 81fff5fdee2..41497d19e07 100644 --- a/packages/common/test/services/logger.service.spec.ts +++ b/packages/common/test/services/logger.service.spec.ts @@ -1,5 +1,4 @@ import { expect } from 'chai'; -import 'reflect-metadata'; import * as sinon from 'sinon'; import { ConsoleLogger, Logger, LoggerService, LogLevel } from '../../services'; @@ -475,6 +474,85 @@ describe('Logger', () => { expect(processStdoutWriteSpy.secondCall.firstArg).to.include(Test.name); }); }); + + describe('forceConsole option', () => { + let consoleLogSpy: sinon.SinonSpy; + let consoleErrorSpy: sinon.SinonSpy; + let processStdoutWriteStub: sinon.SinonStub; + let processStderrWriteStub: sinon.SinonStub; + + beforeEach(() => { + // Stub process.stdout.write to prevent actual output and track calls + processStdoutWriteStub = sinon.stub(process.stdout, 'write'); + processStderrWriteStub = sinon.stub(process.stderr, 'write'); + consoleLogSpy = sinon.spy(console, 'log'); + consoleErrorSpy = sinon.spy(console, 'error'); + }); + + afterEach(() => { + processStdoutWriteStub.restore(); + processStderrWriteStub.restore(); + consoleLogSpy.restore(); + consoleErrorSpy.restore(); + }); + + it('should use console.log instead of process.stdout.write when forceConsole is true', () => { + const logger = new ConsoleLogger({ forceConsole: true }); + const message = 'test message'; + + logger.log(message); + + // When forceConsole is true, console.log should be called + expect(consoleLogSpy.called).to.be.true; + expect(consoleLogSpy.firstCall.firstArg).to.include(message); + }); + + it('should use console.error instead of process.stderr.write when forceConsole is true', () => { + const logger = new ConsoleLogger({ forceConsole: true }); + const message = 'error message'; + + logger.error(message); + + expect(consoleErrorSpy.called).to.be.true; + expect(consoleErrorSpy.firstCall.firstArg).to.include(message); + }); + + it('should use console.error for stack traces when forceConsole is true', () => { + const logger = new ConsoleLogger({ forceConsole: true }); + const message = 'error with stack'; + const stack = 'Error: test\n at :1:1'; + + logger.error(message, stack); + + expect(consoleErrorSpy.calledTwice).to.be.true; + expect(consoleErrorSpy.firstCall.firstArg).to.include(message); + expect(consoleErrorSpy.secondCall.firstArg).to.equal(stack); + }); + + it('should use process.stdout.write when forceConsole is false', () => { + const logger = new ConsoleLogger({ forceConsole: false }); + const message = 'test message'; + + logger.log(message); + + expect(processStdoutWriteStub.called).to.be.true; + expect(processStdoutWriteStub.firstCall.firstArg).to.include(message); + expect(consoleLogSpy.called).to.be.false; + }); + + it('should work with JSON mode and forceConsole', () => { + const logger = new ConsoleLogger({ json: true, forceConsole: true }); + const message = 'json message'; + + logger.log(message); + + expect(consoleLogSpy.called).to.be.true; + + const output = consoleLogSpy.firstCall.firstArg; + const json = JSON.parse(output); + expect(json.message).to.equal(message); + }); + }); }); describe('[instance methods]', () => { diff --git a/packages/core/nest-factory.ts b/packages/core/nest-factory.ts index 3cc157d9e29..ab62ea0f816 100644 --- a/packages/core/nest-factory.ts +++ b/packages/core/nest-factory.ts @@ -10,6 +10,7 @@ import { import { NestMicroserviceOptions } from '@nestjs/common/interfaces/microservices/nest-microservice-options.interface'; import { NestApplicationContextOptions } from '@nestjs/common/interfaces/nest-application-context-options.interface'; import { NestApplicationOptions } from '@nestjs/common/interfaces/nest-application-options.interface'; +import { ConsoleLogger } from '@nestjs/common/services/console-logger.service'; import { Logger } from '@nestjs/common/services/logger.service'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; import { isFunction, isNil } from '@nestjs/common/utils/shared.utils'; @@ -299,9 +300,14 @@ export class NestFactoryStatic { if (!options) { return; } - const { logger, bufferLogs, autoFlushLogs } = options; + const { logger, bufferLogs, autoFlushLogs, forceConsole } = options; if ((logger as boolean) !== true && !isNil(logger)) { Logger.overrideLogger(logger); + } else if (forceConsole) { + // If no custom logger is provided but forceConsole is true, + // create a ConsoleLogger with forceConsole option + const consoleLogger = new ConsoleLogger({ forceConsole: true }); + Logger.overrideLogger(consoleLogger); } if (bufferLogs) { Logger.attachBuffer();