diff --git a/_repl.ts b/_repl.ts new file mode 100644 index 0000000..09a082c --- /dev/null +++ b/_repl.ts @@ -0,0 +1,289 @@ +import type { LogRecord, Logger as LogTapeLogger, Sink, LogLevel as LogTapeLogLevel } from "@logtape/logtape"; +import { getLogger } from "@logtape/logtape"; + +export const generalLogger = getLogger(["@okikio/logger", "general"]); + +/** + * A logging template prefix function. It is used to log a message in + * a {@link LogCallback} function. + * @param message The message template strings array. + * @param values The message template values. + * @returns The rendered message array. + */ +export type LogTemplatePrefix = ( + message: TemplateStringsArray, + ...values: unknown[] +) => unknown[]; + +/** + * A logging callback function. It is used to defer the computation of a + * message template until it is actually logged. + * @param prefix The message template prefix. + * @returns The rendered message array. + */ +export type LogCallback = (prefix: LogTemplatePrefix) => unknown[]; +export type LogLevel = LogTapeLogLevel; + +export interface LogTapeLoggerImpl extends LogTapeLogger { + emit(record: LogRecord, bypassSinks?: Set): void; + log( + level: LogLevel, + message: string, + properties: Record | (() => Record), + bypassSinks?: Set, + ): void; +} + +export interface LoggerOptions { + logger?: L; + template?: unknown; + context?: Record; + loggers?: Record; // Custom loggers passed as name-logger pairs +} + +export class Logger { + #loggers: Record; + #currentLogger: L | "general"; + #message?: unknown; + #context: Record; + + constructor(options: LoggerOptions = {} as LoggerOptions) { + const { + logger = "general" as L, + template, + context = {}, + loggers = {}, + } = options; + this.#loggers = { general: generalLogger } as Record; + this.#message = template; + this.#context = context; + this.#currentLogger = logger; + + // Dynamically create getters for custom loggers + for (const [name, _logger] of Object.entries(loggers)) { + this.#loggers[name as L] = _logger; + this.#createLoggerGetter(name as L); + } + } + + #createLoggerGetter(name: L): void { + Object.defineProperty(this, name, { + get() { + this.#currentLogger = name; + return this; + }, + configurable: true, + enumerable: true, + }); + } + + with(options: LoggerOptions = {}): this { + if (options.logger) { + this.#currentLogger = options.logger; + if (!this.#loggers[options.logger]) { + throw new Error(`Logger "${options.logger}" is not registered.`); + } + } + if (options.template) this.#message = options.template; + if (options.context) { + Object.assign(this.#context, options.context); + } + return this; + } + + getLogger() { return this.#loggers[this.#currentLogger]; } + #log(level: LogLevel, _message: unknown, ...args: unknown[]): void { + const logger = this.#loggers[this.#currentLogger] as LogTapeLoggerImpl; + const context = structuredClone(this.#context); + + let message = _message ?? this.#message ?? _message; + if (typeof _message === "string") { + const possibleArg = typeof args[0] === "function" ? args[0]?.(context) : args[0]; + if (possibleArg) Object.assign(context, (possibleArg ?? {}) as Record); + logger.log(level, message, (context ?? {}) as Record, args[1] as Set); + } + + // if (typeof message === "string" ) { + // const possibleArg = typeof args[0] === "function" ? args[0]?.(context) : args[0]; + // if (possibleArg) Object.assign(context, (possibleArg ?? {}) as Record); + // logger.log(level, message, (context ?? {}) as Record, args[1] as Set); + // } else if (typeof template === "object" && !Array.isArray(template)) { + // message = this.#message || "Log message"; // Default message if no template is provided + // Object.assign(context, template as Record); + + // const possibleArg = typeof args[0] === "function" ? args[0]?.(context) : args[0]; + // if (possibleArg) Object.assign(context, (possibleArg ?? {}) as Record); + // logger[level](message, context); + // } else { + // logger[level](template as TemplateStringsArray, ...args); + // } + } + + #logLazily( + level: LogLevel, + callback: LogCallback, + properties: Record | (() => Record) = {}, + bypassSinks?: Set, + ): void { + const logger = this.#loggers[this.#currentLogger] as LogTapeLoggerImpl; + let msg: unknown[] | undefined = undefined; + const record: LogRecord = { + category: logger.category, + level, + get message() { + if (msg == null) { + msg = callback((tpl, ...values) => renderMessage(tpl, values)); + } + return msg; + }, + timestamp: Date.now(), + properties: {}, + }; + + let cachedProps: Record | undefined = undefined; + Object.assign(record, typeof properties === "function" ? { + get properties() { + if (cachedProps == null) cachedProps = properties(); + return cachedProps; + }, + } : { properties }); + logger.emit(record, bypassSinks); + } + + #logTemplate( + level: LogLevel, + messageTemplate: TemplateStringsArray, + values: unknown[], + properties: Record | (() => Record) = {}, + bypassSinks?: Set, + ): void { + const logger = this.#loggers[this.#currentLogger] as LogTapeLoggerImpl; + const record: LogRecord = { + category: logger.category, + level, + message: renderMessage(messageTemplate, values), + timestamp: Date.now(), + properties: {}, + }; + + let cachedProps: Record | undefined = undefined; + Object.assign(record, typeof properties === "function" ? { + get properties() { + if (cachedProps == null) cachedProps = properties(); + return cachedProps; + }, + } : { properties }); + logger.emit(record, bypassSinks); + } + + error(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("error", templateOrContext, ...args); + return this; + } + + warn(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("warn", templateOrContext, ...args); + return this; + } + + info(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("info", templateOrContext, ...args); + return this; + } + + debug(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("debug", templateOrContext, ...args); + return this; + } + + fatal(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("fatal", templateOrContext, ...args); + return this; + } + + context(context: Record): this { + Object.assign(this.#context, context); + return this; + } + + message(message: unknown): this { + this.#message = message; + return this; + } + + // Shortcut for general logger + get general(): this { + this.#currentLogger = "general"; + return this; + } +} + +// Interface to extend Logger with custom loggers +export type LoggerWithCustomLoggers = Logger & { + [P in L]: Logger; +} + +// Function to create a logger with typed custom loggers +export function createLogger(options: LoggerOptions = {}): LoggerWithCustomLoggers { + return new Logger(options) as LoggerWithCustomLoggers; +} + +/** + * Parse a message template into a message template array and a values array. + * @param template The message template. + * @param properties The values to replace placeholders with. + * @returns The message template array and the values array. + */ +export function parseMessageTemplate( + template: string, + properties: Record, +): readonly unknown[] { + const message: unknown[] = []; + let part = ""; + for (let i = 0; i < template.length; i++) { + const char = template.charAt(i); + const nextChar = template.charAt(i + 1); + + if (char == "{" && nextChar == "{") { + // Escaped { character + part = part + char; + i++; + } else if (char == "}" && nextChar == "}") { + // Escaped } character + part = part + char; + i++; + } else if (char == "{") { + // Start of a placeholder + message.push(part); + part = ""; + } else if (char == "}") { + // End of a placeholder + message.push(properties[part]); + part = ""; + } else { + // Default case + part = part + char; + } + } + message.push(part); + return message; +} + +/** + * Render a message template with values. + * @param template The message template. + * @param values The message template values. + * @returns The message template values interleaved between the substitution + * values. + */ +export function renderMessage( + template: TemplateStringsArray, + values: readonly unknown[], +): unknown[] { + const args = []; + for (let i = 0; i < template.length; i++) { + args.push(template[i]); + if (i < values.length) args.push(values[i]); + } + return args; +} \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..956b5d6 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,43 @@ +{ + "icon": "📊", + "name": "@okikio/logger", + "version": "0.0.0", + "description": "Flexible, structured logging built on @logtape/logtape-—powerful, zero-dependency, and ready for any runtime.", + "keywords": [ + "logging", + "logtape", + "esm" + ], + "license": "MIT", + "author": "okikio (Okiki Ojo)", + "funding": "https://github.com/sponsors/okikio", + "homepage": "https://github.com/okikio/logger", + "supported": [ + "deno", + "node", + "bun", + "cloudflare-workers", + "browsers" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/okikio/logger.git" + }, + "npm": true, + "lock": false, + "exports": { + ".": "./mod.ts", + "./logtape": "./logtape.ts", + "./deno.jsonc": "./deno.jsonc" + }, + "imports": { + "@logtape/logtape": "jsr:@logtape/logtape@^0.4.2", + "@std/testing": "jsr:@std/testing@1", + "@libs/testing": "jsr:@libs/testing@2" + }, + "tasks": { + "test": "deno test --clean --trace-leaks", + "dev": "deno task test --filter='/^\\[deno\\]/'", + "test:deno-future": "DENO_FUTURE=1 && deno task test:deno" + } +} diff --git a/logtape.ts b/logtape.ts new file mode 100644 index 0000000..1e14edd --- /dev/null +++ b/logtape.ts @@ -0,0 +1 @@ +export * from "@logtape/logtape"; \ No newline at end of file diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..08583f7 --- /dev/null +++ b/mod.ts @@ -0,0 +1,228 @@ +import type { LogRecord, Logger as LogTapeLogger, Sink } from "@logtape/logtape"; +import { getLogger } from "@logtape/logtape"; + +export const generalLogger = getLogger(["@okikio/logger", "general"]); + +/** + * A logging template prefix function. It is used to log a message in + * a {@link LogCallback} function. + * @param message The message template strings array. + * @param values The message template values. + * @returns The rendered message array. + */ +export type LogTemplatePrefix = ( + message: TemplateStringsArray, + ...values: unknown[] +) => unknown[]; + +/** + * A logging callback function. It is used to defer the computation of a + * message template until it is actually logged. + * @param prefix The message template prefix. + * @returns The rendered message array. + */ +export type LogCallback = (prefix: LogTemplatePrefix) => unknown[]; +export type LogLevel = "error" | "debug" | "fatal" | "info" | "warn"; + +export interface LogTapeLoggerImpl extends LogTapeLogger { + emit(record: LogRecord, bypassSinks?: Set): void; + log( + level: LogLevel, + message: string, + properties: Record | (() => Record), + bypassSinks?: Set, + ): void; +} + +export interface LoggerOptions { + logger?: L; + template?: unknown; + context?: Record; + loggers?: Record; // Custom loggers passed as name-logger pairs +} + +export class Logger { + #loggers: Record; + #currentLogger: L | "general"; + #message?: unknown; + #context: Record; + + constructor(options: LoggerOptions = {} as LoggerOptions) { + const { + logger = "general" as L, + template, + context = {}, + loggers = {}, + } = options; + this.#loggers = { general: generalLogger } as Record; + this.#message = template; + this.#context = context; + this.#currentLogger = logger; + + // Dynamically create getters for custom loggers + for (const [name, _logger] of Object.entries(loggers)) { + this.#loggers[name as L] = _logger; + this.#createLoggerGetter(name as L); + } + } + + #createLoggerGetter(name: L): void { + Object.defineProperty(this, name, { + get() { + this.#currentLogger = name; + return this; + }, + configurable: true, + enumerable: true, + }); + } + + with(options: LoggerOptions = {}): this { + if (options.logger) { + this.#currentLogger = options.logger; + if (!this.#loggers[options.logger]) { + throw new Error(`Logger "${options.logger}" is not registered.`); + } + } + if (options.template) this.#message = options.template; + if (options.context) { + Object.assign(this.#context, options.context); + } + return this; + } + + getLogger() { return this.#loggers[this.#currentLogger]; } + #log(level: LogLevel, _message: unknown, ...args: unknown[]): void { + const logger = this.#loggers[this.#currentLogger] as LogTapeLoggerImpl; + const context = structuredClone(this.#context); + + let message = ""; + if (typeof _message === "string" ) { + message = _message; + + const possibleArg = typeof args[0] === "function" ? args[0]?.(context) : args[0]; + if (possibleArg) Object.assign(context, (possibleArg ?? {}) as Record); + logger.log(level, message, (context ?? {}) as Record, args[1] as Set); + } else if (typeof _message === "object" && !Array.isArray(_message)) { + message = this.#message as string || "Log message"; // Default message if no template is provided + Object.assign(context, _message as Record); + + const possibleArg = typeof args[0] === "function" ? args[0]?.(context) : args[0]; + if (possibleArg) Object.assign(context, (possibleArg ?? {}) as Record); + logger[level](message, context); + } else { + logger[level](_message as TemplateStringsArray, ...args); + } + } + + error(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("error", templateOrContext, ...args); + return this; + } + + warn(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("warn", templateOrContext, ...args); + return this; + } + + info(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("info", templateOrContext, ...args); + return this; + } + + debug(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("debug", templateOrContext, ...args); + return this; + } + + fatal(templateOrContext: TemplateStringsArray | string | LogCallback | Record, ...args: unknown[]) { + this.#log("fatal", templateOrContext, ...args); + return this; + } + + context(context: Record): this { + Object.assign(this.#context, context); + return this; + } + + message(message: unknown): this { + this.#message = message; + return this; + } + + // Shortcut for general logger + get general(): this { + this.#currentLogger = "general"; + return this; + } +} + +// Interface to extend Logger with custom loggers +export type LoggerWithCustomLoggers = Logger & { + [P in L]: Logger; +} + +// Function to create a logger with typed custom loggers +export function createLogger(options: LoggerOptions = {}): LoggerWithCustomLoggers { + return new Logger(options) as LoggerWithCustomLoggers; +} + +/** + * Parse a message template into a message template array and a values array. + * @param template The message template. + * @param properties The values to replace placeholders with. + * @returns The message template array and the values array. + */ +export function parseMessageTemplate( + template: string, + properties: Record, +): readonly unknown[] { + const message: unknown[] = []; + let part = ""; + for (let i = 0; i < template.length; i++) { + const char = template.charAt(i); + const nextChar = template.charAt(i + 1); + + if (char == "{" && nextChar == "{") { + // Escaped { character + part = part + char; + i++; + } else if (char == "}" && nextChar == "}") { + // Escaped } character + part = part + char; + i++; + } else if (char == "{") { + // Start of a placeholder + message.push(part); + part = ""; + } else if (char == "}") { + // End of a placeholder + message.push(properties[part]); + part = ""; + } else { + // Default case + part = part + char; + } + } + message.push(part); + return message; +} + +/** + * Render a message template with values. + * @param template The message template. + * @param values The message template values. + * @returns The message template values interleaved between the substitution + * values. + */ +export function renderMessage( + template: TemplateStringsArray, + values: readonly unknown[], +): unknown[] { + const args = []; + for (let i = 0; i < template.length; i++) { + args.push(template[i]); + if (i < values.length) args.push(values[i]); + } + return args; +} \ No newline at end of file diff --git a/mod_e2e_test.ts b/mod_e2e_test.ts new file mode 100644 index 0000000..a7a52ee --- /dev/null +++ b/mod_e2e_test.ts @@ -0,0 +1,236 @@ +// import type { LogRecord, Sink } from '@logtape/logtape'; +// import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from 'vitest'; + +// import { configure, reset, getLevelFilter, withFilter, dispose, getLogger } from '@logtape/logtape'; +// import { createLogger } from './mod.ts'; // Adjust the import path based on your project structure + +// // Buffer to collect log records for testing +// let buffer: LogRecord[] = []; + +// // Custom buffer sink that collects logs +// const bufferSink: Sink & Disposable = (record) => { +// buffer.push(record); +// }; +// bufferSink[Symbol.dispose] = () => { +// buffer = []; // Clear buffer on disposal +// }; + +// // Configuration for LogTape +// beforeAll(async () => { +// await configure({ +// sinks: { +// buffer: bufferSink, +// }, +// filters: {}, +// loggers: [ +// { category: ['@bundle/core', 'init'], level: 'debug', sinks: ['buffer'] }, +// { category: ['@bundle/core', 'build'], level: 'debug', sinks: ['buffer'] }, +// { category: ['@bundle/core', 'general'], level: 'debug', sinks: ['buffer'] }, +// { category: ['@bundle/core', 'custom'], level: 'debug', sinks: ['buffer'] }, +// ], +// }); +// }); + +// afterEach(() => { +// // Clear buffer after each test +// buffer = []; +// }); + +// afterAll(async () => { +// await reset(); // Reset LogTape configuration after all tests +// }); + +// describe('Logger Abstraction E2E Tests - LogTape Features', () => { +// const loggers = { +// init: getLogger(['@bundle/core', 'init']), +// build: getLogger(['@bundle/core', 'build']), +// general: getLogger(['@bundle/core', 'general']), +// custom: getLogger(['@bundle/core', 'custom']), +// }; + +// const logger = createLogger({ loggers }); + +// const sampleLogMessage = 'Sample log message'; +// const sampleTemplateLogMessage = ['Sample template log message ', '']; +// const sampleValue = 42; + +// it('should support hierarchical categories', () => { +// logger.init.info`Initialization started`; +// logger.build.debug`Building project`; + +// expect(buffer.length).toBe(2); +// expect(buffer[0].message).toEqual(['Initialization started']); +// expect(buffer[1].message).toEqual(['Building project']); +// }); + +// it('should support structured logging', () => { +// logger.general.info('Structured log', { key: 'value' }); + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Structured log']); +// expect(buffer[0].properties).toEqual({ key: 'value' }); +// }); + +// it('should support template literals for log messages', () => { +// logger.custom.debug`${sampleTemplateLogMessage[0]}${sampleValue}`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(["", sampleTemplateLogMessage[0], "", sampleValue, ""]); +// }); + +// it('should support deferring the computation of log messages', () => { +// const expensiveComputation = vi.fn(() => 99); +// logger.build.debug`Deferred computation ${expensiveComputation()}`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Deferred computation ', 99, '']); +// expect(expensiveComputation).toHaveBeenCalled(); +// }); + +// it('should support custom sinks', () => { +// logger.general.info`Testing custom sink`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Testing custom sink']); +// }); + +// it('should support level filtering', async () => { +// await reset(); // Reset to reconfigure with a filter +// await configure({ +// sinks: { +// buffer: bufferSink, +// }, +// filters: { +// infoOrHigher: getLevelFilter('info'), +// }, +// loggers: [ +// { category: ['@bundle/core', 'general'], level: 'debug', sinks: ['buffer'], filters: ['infoOrHigher'] }, +// ], +// }); + +// logger.general.debug`This debug message should not appear`; +// logger.general.info`This info message should appear`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['This info message should appear']); +// }); + +// it('should support sink-specific filtering', async () => { +// await reset(); // Reset to reconfigure with a sink filter +// await configure({ +// sinks: { +// filteredBuffer: withFilter(bufferSink, (log) => log.level === 'error'), +// }, +// filters: {}, +// loggers: [ +// { category: ['@bundle/core', 'build'], level: 'debug', sinks: ['filteredBuffer'] }, +// ], +// }); + +// logger.build.warn`This warning should not appear`; +// logger.build.error`This error should appear`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['This error should appear']); +// }); + +// it('should support disposable sinks', async () => { +// let disposed = false; + +// const disposableSink: Sink & Disposable = (record) => { +// buffer.push(record); +// }; +// disposableSink[Symbol.dispose] = () => { +// disposed = true; +// buffer = []; // Clear buffer on disposal +// }; + +// await reset(); // Reset to reconfigure with disposable sink +// await configure({ +// sinks: { +// disposable: disposableSink, +// }, +// filters: {}, +// loggers: [ +// { category: ['@bundle/core', 'general'], level: 'debug', sinks: ['disposable'] }, +// ], +// }); + +// logger.general.info`Logging before disposal`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Logging before disposal']); + +// await dispose(); // Dispose of the sink + +// expect(disposed).toBe(true); +// expect(buffer.length).toBe(0); +// }); + +// it('should support asynchronous disposable sinks', async () => { +// let disposed = false; + +// const asyncDisposableSink: Sink & AsyncDisposable = (record) => { +// buffer.push(record); +// }; +// asyncDisposableSink[Symbol.asyncDispose] = async () => { +// disposed = true; +// buffer = [] +// }; + +// await reset(); // Reset to reconfigure with async disposable sink +// await configure({ +// sinks: { +// asyncDisposable: asyncDisposableSink, +// }, +// filters: {}, +// loggers: [ +// { category: ['@bundle/core', 'general'], level: 'debug', sinks: ['asyncDisposable'] }, +// ], +// }); + +// logger.general.info`Logging before async disposal`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Logging before async disposal']); + +// await dispose(); // Dispose of the sink asynchronously + +// expect(disposed).toBe(true); +// expect(buffer.length).toBe(0); +// }); + +// it('should support explicit disposal', async () => { +// await reset(); // Reset to reconfigure with disposable sink +// await configure({ +// sinks: { +// buffer: bufferSink, +// }, +// filters: {}, +// loggers: [ +// { category: ['@bundle/core', 'general'], level: 'debug', sinks: ['buffer'] }, +// ], +// }); + +// logger.general.info`Logging before explicit disposal`; + +// expect(buffer.length).toBe(1); +// expect(buffer[0].message).toEqual(['Logging before explicit disposal']); + +// await dispose(); + +// // Verify that no further logs are captured after disposal +// logger.general.info`Logging after disposal`; +// expect(buffer.length).toBe(1); // Should still be 1, as further logging should be discarded +// }); + +// it('should support resetting configuration', async () => { +// logger.general.info`Logging before reset`; +// expect(buffer.length).toBe(1); + +// await reset(); + +// logger.general.info`Logging after reset`; +// expect(buffer.length).toBe(1); // Should not log after reset, as configuration was reset +// }); +// }); diff --git a/mod_test.ts b/mod_test.ts new file mode 100644 index 0000000..5a1a25e --- /dev/null +++ b/mod_test.ts @@ -0,0 +1,299 @@ +import type { Logger } from "@logtape/logtape"; + +import { describe, it, afterEach } from "@std/testing/bdd"; +import { spy, assertSpyCalls, } from "@std/testing/mock"; + +import { expect } from "@libs/testing"; +import { createLogger } from './mod.ts'; // Adjust the import based on your file structure + +describe('Logger Library', () => { + const mockLogger = { + error: spy(() => { }), + warn: spy(() => { }), + info: spy(() => { }), + debug: spy(() => { }), + fatal: spy(() => { }), + }; + + const loggers = { + general: mockLogger as unknown as Logger, + init: mockLogger as unknown as Logger, + build: mockLogger as unknown as Logger, + customLogger: mockLogger as unknown as Logger, + }; + + afterEach(() => { + // Reset mock call history + // mockLogger.error.restore(); + // mockLogger.warn.restore(); + // mockLogger.info.restore(); + // mockLogger.debug.restore(); + // mockLogger.fatal.restore(); + }); + + + describe('Basic Logging Operations', () => { + it('should log to the general logger by default', () => { + const logger = createLogger({ loggers }); + logger.info`General Log ${5}`; + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([ + ['General Log ', ''], + 5, + ]); + }); + + it('should log to a specific custom logger', () => { + const logger = createLogger({ loggers }); + logger.init.error('Initialization failed'); + assertSpyCalls(mockLogger.error, 1); + expect(mockLogger.error.calls[0].args).toEqual(['Initialization failed', {}]); + }); + + it('should log to a specific custom logger (using tagged templates)', () => { + const logger = createLogger({ loggers }); + logger.init.error`Initialization failed: ${'test'}`; + assertSpyCalls(mockLogger.error, 1); + expect(mockLogger.error.calls[0].args).toEqual([ + ['Initialization failed: ', ''], + 'test' + ]); + }); + + it('should log with various log levels', () => { + const logger = createLogger({ loggers }); + logger.warn('A warning message'); + logger.debug('Debugging message'); + logger.fatal('A fatal message'); + assertSpyCalls(mockLogger.warn, 1); + expect(mockLogger.warn.calls[0].args).toEqual(['A warning message', {}]); + assertSpyCalls(mockLogger.debug, 1); + expect(mockLogger.debug.calls[0].args).toEqual(['Debugging message', {}]); + assertSpyCalls(mockLogger.fatal, 1); + expect(mockLogger.fatal.calls[0].args).toEqual(['A fatal message', {}]); + }); + + it('should log with various log levels (using tagged templates)', () => { + const logger = createLogger({ loggers }); + logger.warn`A warning message: ${'something'}`; + logger.debug`Debugging message: ${42}`; + logger.fatal`A fatal message: ${new Error('error')}`; + assertSpyCalls(mockLogger.warn, 1); + expect(mockLogger.warn.calls[0].args).toEqual([ + ['A warning message: ', ''], + 'something' + ]); + assertSpyCalls(mockLogger.debug, 1); + expect(mockLogger.debug.calls[0].args).toEqual([ + ['Debugging message: ', ''], + 42 + ]); + assertSpyCalls(mockLogger.fatal, 1); + expect(mockLogger.fatal.calls[0].args).toEqual([ + ['A fatal message: ', ''], + new Error('error') + ]); + }); + }); + + describe('Logger Initialization', () => { + it('should initialize with default options', () => { + const logger = createLogger({}); + expect(logger.general).toBeUndefined(); // General logger is undefined by default + }); + + it('should initialize with a custom template', () => { + const logger = createLogger({ loggers, template: 'Custom Template' }); + logger.info('Logging with custom template'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Logging with custom template', {}]); + }); + + it('should initialize with context data', () => { + const context = { user: 'testUser' }; + const logger = createLogger({ loggers, context }); + logger.info('Logging with context'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Logging with context', context]); + }); + + it('should initialize with multiple custom loggers', () => { + const logger = createLogger({ loggers }); + expect(logger.init).toBe(mockLogger); + expect(logger.build).toBe(mockLogger); + expect(logger.customLogger).toBe(mockLogger); + }); + }); + + describe('Logger Methods', () => { + it('should modify logger options dynamically using with()', () => { + const logger = createLogger({ loggers }); + logger.with({ logger: 'build', template: 'Building...' }).info('Starting build'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Starting build', {}]); + }); + + it('should add context data using value()', () => { + const logger = createLogger({ loggers }); + logger.context({ requestId: '12345' }).info('Request received'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Request received', { requestId: '12345' }]); + }); + + it('should dynamically create getters for custom loggers', () => { + const logger = createLogger({ loggers }); + expect(logger.init).toBe(mockLogger); + expect(logger.build).toBe(mockLogger); + }); + + it('should switch to a custom logger dynamically (using tagged template)', () => { + const logger = createLogger({ loggers }); + logger.init.info`Initialization log: ${'init'}`; + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([ + ['Initialization log: ', ''], + 'init' + ]); + }); + }); + + describe('Error Handling', () => { + it('should throw an error for a non-existent logger', () => { + const logger = createLogger({ loggers }); + // @ts-expect-error nonExistentLogger + expect(() => logger.with({ logger: 'nonExistentLogger' }).info('This should fail')).toThrow('Logger "nonExistentLogger" is not registered.'); + }); + + it('should throw an error if the logger option is provided in with() and it does not exist', () => { + const logger = createLogger({ loggers }); + // @ts-expect-error nonExistentLogger + expect(() => logger.with({ logger: 'nonExistentLogger' })).toThrow('Logger "nonExistentLogger" is not registered.'); + }); + }); + + describe('Log Message Formatting', () => { + it('should log a simple string message', () => { + const logger = createLogger({ loggers }); + logger.info('Simple log message'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Simple log message', {}]); + }); + + it('should log a message with template strings and values', () => { + const logger = createLogger({ loggers }); + logger.info`Template Log ${5}`; + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([ + ['Template Log ', ''], + 5 + ]); + }); + + it('should log a message with a callback function', () => { + const logger = createLogger({ loggers }); + const callback = (prefix: (msg: TemplateStringsArray, ...vals: unknown[]) => unknown[]) => prefix`Callback Log ${5}`; + logger.info(callback); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([callback]); + }); + + it('should log a message with a context object', () => { + const logger = createLogger({ loggers }); + logger.info({ userId: 'abc123' }); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([{ userId: 'abc123' }]); + }); + + it('should merge context data from log call with existing context', () => { + const logger = createLogger({ loggers, context: { user: 'testUser' } }); + logger.info({ requestId: 'abc123' }); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([{ user: 'testUser', requestId: 'abc123' }]); + }); + + it('should use the provided template for logging', () => { + const logger = createLogger({ loggers, template: 'Custom Template' }); + logger.info('Template provided'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Template provided', {}]); + }); + + it('should log with an empty message', () => { + const logger = createLogger({ loggers }); + logger.info(''); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['', {}]); + }); + + it('should handle undefined values in the message template', () => { + const logger = createLogger({ loggers }); + logger.info`Value is ${undefined}`; + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([ + ['Value is ', ''], + undefined + ]); + }); + + it('should log with a default message if only context is provided', () => { + const logger = createLogger({ loggers }); + logger.info({ key: 'value' }); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual([{ key: 'value' }]); + }); + }); + + describe('Integration with LogTapeLogger', () => { + it('should call the correct logger based on the log level', () => { + const logger = createLogger({ loggers }); + logger.error('Error message'); + logger.warn('Warning message'); + logger.debug('Debug message'); + logger.fatal('Fatal message'); + assertSpyCalls(mockLogger.error, 1); + expect(mockLogger.error.calls[0].args).toEqual(['Error message', {}]); + assertSpyCalls(mockLogger.warn, 1); + expect(mockLogger.warn.calls[0].args).toEqual(['Warning message', {}]); + assertSpyCalls(mockLogger.debug, 1); + expect(mockLogger.debug.calls[0].args).toEqual(['Debug message', {}]); + assertSpyCalls(mockLogger.fatal, 1); + expect(mockLogger.fatal.calls[0].args).toEqual(['Fatal message', {}]); + }); + + it('should ensure log level functions are called with correct message and context', () => { + const logger = createLogger({ loggers, context: { sessionId: '123' } }); + logger.warn('This is a warning'); + assertSpyCalls(mockLogger.warn, 1); + expect(mockLogger.warn.calls[0].args).toEqual(['This is a warning', { sessionId: '123' }]); + }); + + it('should allow switching between loggers dynamically', () => { + const logger = createLogger({ loggers }); + logger.init.info('Initializing...'); + logger.build.debug('Building...'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Initializing...', {}]); + assertSpyCalls(mockLogger.debug, 1); + expect(mockLogger.debug.calls[0].args).toEqual(['Building...', {}]); + }); + + it('should call the correct logger getter based on dynamic switching', () => { + const logger = createLogger({ loggers }); + logger.init.info('Switched to init logger'); + assertSpyCalls(mockLogger.info, 1); + expect(mockLogger.info.calls[0].args).toEqual(['Switched to init logger', {}]); + logger.build.warn('Switched to build logger'); + assertSpyCalls(mockLogger.warn, 1); + expect(mockLogger.warn.calls[0].args).toEqual(['Switched to build logger', {}]); + }); + + it('should catch TypeScript errors for incorrect log level usage', () => { + const logger = createLogger({ loggers }); + expect(() => { + // Attempt to use a non-existent log level should trigger a TypeScript error + // @ts-expect-error nonExistentLogger + logger.init.verbose('This should fail'); + }).toThrow('logger.init.verbose is not a function'); + }); + }); +});