diff --git a/.gitignore b/.gitignore index b0a5c34..74eaed1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules/ /dist/ +package-lock.json diff --git a/__tests__/index.ts b/__tests__/index.ts index 4abb235..659f432 100644 --- a/__tests__/index.ts +++ b/__tests__/index.ts @@ -1,25 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { advanceTo } from 'jest-date-mock' -import { Logger, LEVEL } from '../src' +import { Logger, LEVEL, formatLogLine, LogLine } from '../src' advanceTo('2019-01-01T00:00:00.000Z') -test('can log a message', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() +// Helper to capture log output using the writer option +function createCaptureLogger(options: { level?: LEVEL; devMode?: boolean } = {}) { + const output: string[] = [] + const logger = new Logger({ + ...options, + writer: (s) => output.push(s), + }) + return { logger, output } +} - const logger = new Logger() +test('can log a message', () => { + const { logger, output } = createCaptureLogger() logger.log('info', 'test') - expect(spy).toHaveBeenCalledWith(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test"}\n`) + expect(output[0]).toBe(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test"}`) }) test('allows the log level to be limited', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger({ level: 'error' }) + const { logger, output } = createCaptureLogger({ level: 'error' }) logger.log('info', 'test') - expect(spy).not.toHaveBeenCalled() + expect(output).toHaveLength(0) }) test('validates the level', () => { @@ -29,97 +35,80 @@ test('validates the level', () => { }) test('can log data', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { some: 'data' }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}\n` - ) + expect(output[0]).toBe(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}`) }) interface fixture1 { - fixture2?: any; + fixture2?: any } test('handles circular references', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() + const { logger, output } = createCaptureLogger() const fixture1: fixture1 = {} const fixture2: object = { fixture1 } fixture1.fixture2 = fixture2 - const logger = new Logger() logger.log('info', 'test', fixture1) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"fixture2":{"fixture1":"[Circular]"}}}\n` + expect(output[0]).toBe( + `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"fixture2":{"fixture1":"[Circular]"}}}` ) }) test('handles Buffers', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { buffer: Buffer.alloc(2) }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"buffer":{"type":"Buffer","data":[0,0]}}}\n` + expect(output[0]).toBe( + `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"buffer":{"type":"Buffer","data":[0,0]}}}` ) }) test('handles BigInts', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { bigint: BigInt('999999999999999999999') }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"bigint":"999999999999999999999"}}\n` + expect(output[0]).toBe( + `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"bigint":"999999999999999999999"}}` ) }) test('handles Maps', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { map: new Map([['test', 'map']]) }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"map":[["test","map"]]}}\n` + expect(output[0]).toBe( + `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"map":[["test","map"]]}}` ) }) test('handles Sets', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { set: new Set(['test']) }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"set":["test"]}}\n` - ) + expect(output[0]).toBe(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"set":["test"]}}`) }) interface dataError { data: { error: { - message: string, - name: string, - stack: string[], + message: string + name: string + stack: string[] } } } test('handles Errors', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', { error: new Error('Request timeout') }) - expect(spy).toHaveBeenCalled() - const log: dataError = JSON.parse(spy.mock.calls[0][0] as string) as dataError; + expect(output).toHaveLength(1) + const log: dataError = JSON.parse(output[0]) as dataError expect(log).toMatchObject({ data: { error: { @@ -135,13 +124,11 @@ test('handles Errors', () => { }) test('handles Errors at the top level', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.log('info', 'test', new Error('Request timeout')) - expect(spy).toHaveBeenCalled() - const log: dataError = JSON.parse(spy.mock.calls[0][0] as string) as dataError; + expect(output).toHaveLength(1) + const log: dataError = JSON.parse(output[0]) as dataError expect(log).toMatchObject({ data: { message: 'Request timeout', @@ -154,66 +141,51 @@ test('handles Errors at the top level', () => { }) class CustomError extends Error { - serviceName?: string; + serviceName?: string } test('logs additional properties on Errors', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() const error: CustomError = new Error('Request timeout') error.serviceName = 'test' logger.log('info', 'test', { error }) - expect(spy).toHaveBeenCalled() - const log: dataError = JSON.parse(spy.mock.calls[0][0] as string) as dataError + expect(output).toHaveLength(1) + const log: dataError = JSON.parse(output[0]) as dataError expect(log).toHaveProperty('data.error.serviceName') }) test('devMode: can log a message', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger({ devMode: true }) + const { logger, output } = createCaptureLogger({ devMode: true }) logger.log('info', 'test') - expect(spy).toHaveBeenCalled() - expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(output).toHaveLength(1) + expect(output[0]).toMatchInlineSnapshot(` " - INFO: test - " + INFO: test" `) }) test('info alias can log data', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.info('test', { some: 'data' }) - expect(spy).toHaveBeenCalledWith( - `{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}\n` - ) + expect(output[0]).toBe(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}`) }) test('error alias can log data', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger() + const { logger, output } = createCaptureLogger() logger.error('test', { some: 'data' }) - expect(spy).toHaveBeenCalledWith( - `{"level":"ERROR","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}\n` - ) + expect(output[0]).toBe(`{"level":"ERROR","time":"2019-01-01T00:00:00.000Z","message":"test","data":{"some":"data"}}`) }) test('devMode: can log data', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger({ devMode: true }) + const { logger, output } = createCaptureLogger({ devMode: true }) logger.log('info', 'test', { some: 'data', nested: { buffer: Buffer.alloc(2) } }) - expect(spy).toHaveBeenCalled() - expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(output).toHaveLength(1) + expect(output[0]).toMatchInlineSnapshot(` " INFO: test some: data @@ -222,41 +194,95 @@ test('devMode: can log data', () => { type: Buffer data: - 0 - - 0 - " + - 0" `) }) test('devMode: colors warn level yellow', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger({ devMode: true }) + const { logger, output } = createCaptureLogger({ devMode: true }) logger.log('warn', 'test') - expect(spy).toHaveBeenCalled() - expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(output).toHaveLength(1) + expect(output[0]).toMatchInlineSnapshot(` " - WARN: test - " + WARN: test" `) }) test('devMode: colors error level red', () => { - const spy = jest.spyOn(process.stdout, 'write').mockImplementation() - - const logger = new Logger({ devMode: true }) + const { logger, output } = createCaptureLogger({ devMode: true }) logger.log('error', 'test1') logger.log('crit', 'test2') - expect(spy).toBeCalledTimes(2) - expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(output).toHaveLength(2) + expect(output[0]).toMatchInlineSnapshot(` " - ERROR: test1 - " + ERROR: test1" `) - expect(spy.mock.calls[1][0]).toMatchInlineSnapshot(` + expect(output[1]).toMatchInlineSnapshot(` " - CRIT: test2 - " + CRIT: test2" `) }) + +// Tests for the new formatLogLine pure function +describe('formatLogLine', () => { + test('formats a basic log line as JSON', () => { + const logLine: LogLine = { + level: 'INFO', + time: '2019-01-01T00:00:00.000Z', + message: 'test message', + } + + const result = formatLogLine(logLine, false) + expect(result).toBe('{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test message"}') + }) + + test('formats a log line with data as JSON', () => { + const logLine: LogLine = { + level: 'INFO', + time: '2019-01-01T00:00:00.000Z', + message: 'test message', + data: { foo: 'bar' }, + } + + const result = formatLogLine(logLine, false) + expect(result).toBe( + '{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test message","data":{"foo":"bar"}}' + ) + }) + + test('formats a log line in dev mode', () => { + const logLine: LogLine = { + level: 'INFO', + time: '2019-01-01T00:00:00.000Z', + message: 'test message', + } + + const result = formatLogLine(logLine, true) + expect(result).toContain('INFO: test message') + }) + + test('handles BigInt in data', () => { + const logLine: LogLine = { + level: 'INFO', + time: '2019-01-01T00:00:00.000Z', + message: 'test', + data: { bigint: BigInt('12345678901234567890') }, + } + + const result = formatLogLine(logLine, false) + expect(result).toContain('"bigint":"12345678901234567890"') + }) +}) + +// Test that default writer still works (backwards compatibility) +test('defaults to stdout when no writer provided', () => { + const spy = jest.spyOn(process.stdout, 'write').mockImplementation() + + const logger = new Logger() + logger.log('info', 'test') + + expect(spy).toHaveBeenCalledWith(`{"level":"INFO","time":"2019-01-01T00:00:00.000Z","message":"test"}\n`) + spy.mockRestore() +}) diff --git a/src/index.ts b/src/index.ts index 8cf9e04..b79937e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,11 +19,12 @@ const LEVELS_MAP = { /** Available log levels. */ export type LEVEL = keyof typeof LEVELS_MAP -interface LogLine { +/** Structure of a log line before formatting. */ +export interface LogLine { level: string time: string message: string - data: unknown + data?: unknown } /** Checks that the level is valid */ @@ -79,6 +80,14 @@ function jsonStringifyReplacer(_key: string, value: unknown): any { return value } +/** A function that writes log output. */ +export type LogWriter = (output: string) => void + +/** The default writer that outputs to stdout. */ +export const defaultWriter: LogWriter = (output: string) => { + process.stdout.write(output + '\n') +} + /** Options that can be passed to the Logger constructor. */ export interface LoggerOptions { /** @@ -91,12 +100,63 @@ export interface LoggerOptions { * @default process.env.NODE_ENV === 'development' */ devMode?: boolean + /** + * Custom writer function for log output. Useful for testing or redirecting logs. + * @default defaultWriter (writes to process.stdout) + */ + writer?: LogWriter +} + +/** + * Formats a log line object into a string. + * @param logLineObject The log line object containing level, time, message, and optional data. + * @param devMode Whether to format for development mode (human-readable) or production (JSON). + * @returns The formatted log string. + */ +export function formatLogLine(logLineObject: LogLine, devMode: boolean): string { + // Create JSON string with all the exotic values converted to JSON safe versions + let logLine = safeStringify(logLineObject, jsonStringifyReplacer) + + // Format the logs in a human friendly way in development mode + if (devMode) { + // Construct the main log line and add some highlighting styles + // Just parse the production log because it already has all the data conversions applied + const log: LogLine = JSON.parse(logLine) as LogLine + logLine = chalk.bold(`\n${log.level}: ${log.message}`) + + const level = log.level.toLowerCase() as LEVEL + if (level === 'warn') { + logLine = chalk.yellow(logLine) + } else if (LEVELS_MAP[level] <= LEVELS_MAP.error) { + logLine = chalk.red(logLine) + } + + // Convert data to a compact and readable format + if (log.data) { + let data = yaml.safeDump(log.data, { schema: yaml.JSON_SCHEMA, lineWidth: Infinity }) + + // Indent the data slightly + data = data + .trim() + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + + // Shorten the absolute file paths + data = replaceString(data, process.cwd(), '.') + + logLine += `\n${data}` + } + } + + return logLine } /** Creates a new logger instance. */ export class Logger { level: LEVEL = 'debug' devMode = process.env.NODE_ENV === 'development' + private writer: LogWriter = defaultWriter constructor(options: LoggerOptions = {}) { if (options.level) { @@ -107,6 +167,10 @@ export class Logger { if (options.devMode) { this.devMode = options.devMode } + + if (options.writer) { + this.writer = options.writer + } } /** @@ -127,41 +191,8 @@ export class Logger { data: data, } - // Create JSON string with all the exotic values converted to JSON safe versions - let logLine = safeStringify(logLineObject, jsonStringifyReplacer) - - // Format the logs in a human friendly way in development mode - if (this.devMode) { - // Construct the main log line and add some highlighting styles - // Just parse the production log because it already has all the data conversions applied - const log: LogLine = JSON.parse(logLine) as LogLine - logLine = chalk.bold(`\n${log.level}: ${log.message}`) - - if (level === 'warn') { - logLine = chalk.yellow(logLine) - } else if (LEVELS_MAP[level] <= LEVELS_MAP.error) { - logLine = chalk.red(logLine) - } - - // Convert data to a compact and readable format - if (log.data) { - let data = yaml.safeDump(log.data, { schema: yaml.JSON_SCHEMA, lineWidth: Infinity }) - - // Indent the data slightly - data = data - .trim() - .split('\n') - .map((line) => ` ${line}`) - .join('\n') - - // Shorten the absolute file paths - data = replaceString(data, process.cwd(), '.') - - logLine += `\n${data}` - } - } - - process.stdout.write(logLine + '\n') + const logLine = formatLogLine(logLineObject, this.devMode) + this.writer(logLine) } /**