From 9546dd3ab3e87d2221ba1cb57807542c04888eb4 Mon Sep 17 00:00:00 2001 From: IanKulin Date: Thu, 25 Sep 2025 20:50:13 +0800 Subject: [PATCH] initial --- LICENSE | 21 ++ README.md | 225 ++++++++++++ demo.js | 94 +++++ deno.json | 12 + deno.lock | 52 +++ lib/logger.ts | 483 ++++++++++++++++++++++++++ test/helpers/logger-test-helpers.js | 80 +++++ test/logger.caller-level.test.js | 298 ++++++++++++++++ test/logger.constructor.test.js | 158 +++++++++ test/logger.internal-errors.test.js | 106 ++++++ test/logger.internal-fallback.test.js | 368 ++++++++++++++++++++ test/logger.json-formatter.test.js | 225 ++++++++++++ test/logger.level-management.test.js | 205 +++++++++++ test/logger.robustness.test.js | 146 ++++++++ test/logger.simple-formatter.test.js | 217 ++++++++++++ test/logger.time-formatting.test.js | 200 +++++++++++ test/logger.util-format.test.js | 358 +++++++++++++++++++ 17 files changed, 3248 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 demo.js create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 lib/logger.ts create mode 100644 test/helpers/logger-test-helpers.js create mode 100644 test/logger.caller-level.test.js create mode 100644 test/logger.constructor.test.js create mode 100644 test/logger.internal-errors.test.js create mode 100644 test/logger.internal-fallback.test.js create mode 100644 test/logger.json-formatter.test.js create mode 100644 test/logger.level-management.test.js create mode 100644 test/logger.robustness.test.js create mode 100644 test/logger.simple-formatter.test.js create mode 100644 test/logger.time-formatting.test.js create mode 100644 test/logger.util-format.test.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2e213ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ian Bailey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a758f8 --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +# logger [![NPM version](https://img.shields.io/npm/v/@iankulin/logger.svg?style=flat)](https://www.npmjs.com/package/@iankulin/logger) [![NPM total downloads](https://img.shields.io/npm/dt/@iankulin/logger.svg?style=flat)](https://npmjs.org/package/@iankulin/logger) + +> Flexible console logging utility with colors, and multiple output formats + +## Features + +- **Multiple log levels**: silent, error, warn, info, debug +- **Flexible output formats**: JSON or simple text +- **Caller detection**: Automatically identifies source file and line number based on log level +- **Color support**: Automatic TTY detection with colored output +- **ESM only**: Modern ES module support + +## Install + +Install with [npm](https://npmjs.org/package/@iankulin/logger): + +```sh +$ npm install @iankulin/logger +``` + +## Quick Start + +```js +import Logger from '@iankulin/logger'; + +const logger = new Logger(); +logger.info('Hello from logger'); +logger.error('Something went wrong'); +``` + +## Usage Examples + +### Basic Logging + +```js +import Logger from '@iankulin/logger'; +const logger = new Logger({ level: 'info' }); + +logger.error('Critical error occurred'); +logger.warn('This is a warning'); +logger.info('Informational message'); +logger.debug('Debug info'); // Won't be shown (level is 'info') +``` + +### Log Levels + +The logger supports five log levels (from least to most verbose): + +- `silent` - Suppresses all output +- `error` - Only error messages +- `warn` - Error and warning messages +- `info` - Error, warning, and info messages (default) +- `debug` - All messages + +```js +const logger = new Logger({ level: 'debug' }); + +// All of these will be logged +logger.error('Error message'); +logger.warn('Warning message'); +logger.info('Info message'); +logger.debug('Debug message'); + +// Change level dynamically +logger.level('error'); +logger.info('This will not be logged'); + +// Get current level +console.log(logger.level()); // 'error' +``` + +### Output Formats + +#### JSON Format (Default) + +```js +const logger = new Logger({ format: 'json' }); +logger.info('Hello world'); +``` + +```json +{ + "level": "info", + "levelNumber": 2, + "time": "2025-06-02T12:00:00.000Z", + "pid": 12345, + "hostname": "my-computer", + "msg": "Hello world", + "callerFile": "file:///path/to/file.js", + "callerLine": 3 +} +``` + +#### Simple Format + +```js +const logger = new Logger({ format: 'simple' }); +logger.error('Something failed'); +``` + +``` +[2025-07-04 22:50] [ERROR] [basic.js:3] Something failed +``` + +### Message Formatting + +The logger uses Node.js `util.format()` for message formatting with placeholders like `%s`, `%d`, `%j`. + +```js +const logger = new Logger({ format: 'json' }); + +// String formatting +logger.info('User %s has %d points', 'john', 100); +// Output: {"level":"info","msg":"User john has 100 points",...} + +// JSON formatting +logger.info('Config: %j', { debug: true, port: 3000 }); +// Output: {"level":"info","msg":"Config: {\"debug\":true,\"port\":3000}",...} + +// Simple format example +const simpleLogger = new Logger({ format: 'simple' }); +simpleLogger.warn('Processing file %s (%d bytes)', 'data.txt', 1024); +// Output: [2025-07-05 10:30] [WARN ] [app.js:15] Processing file data.txt (1024 bytes) +``` + +### Custom Colors + +```js +const logger = new Logger({ + colours: { + error: '\x1b[31m', // Red + warn: '\x1b[93m', // Bright yellow + info: '\x1b[36m', // Cyan + debug: '\x1b[90m', // Dark gray + }, +}); +``` + +### Caller Level Control + +Control when caller information (file and line number) is included in log messages. This is useful for performance optimization since caller detection can be expensive. + +```js +// Default: only include caller info for warnings and errors +const logger = new Logger({ callerLevel: 'warn' }); + +logger.error('Critical error'); // Includes caller info +logger.warn('Warning message'); // Includes caller info +logger.info('Info message'); // No caller info +logger.debug('Debug message'); // No caller info +``` + +**JSON Format Output:** + +```json +{"level":"error","msg":"Critical error","callerFile":"/path/to/file.js","callerLine":42} +{"level":"info","msg":"Info message"} +``` + +**Simple Format Output:** + +``` +[2025-07-04 13:13] [ERROR] [app.js:42] Critical error +[2025-07-04 13:13] [INFO ] Info message +``` + +**Available callerLevel Options:** + +- `'silent'` - Never include caller info (best performance) +- `'error'` - Only include caller info for errors +- `'warn'` - Include caller info for warnings and errors (default) +- `'info'` - Include caller info for info, warnings, and errors +- `'debug'` - Always include caller info + +**Performance Tip:** For production applications that primarily log info/debug messages, setting `callerLevel: 'error'` can significantly improve performance by avoiding expensive stack trace analysis for routine logging. + +## Constructor Options + +| Option | Type | Default | Description | +| ------------- | ------ | --------- | ----------------------------------------------------------------------------------------------- | +| `level` | string | `'info'` | Minimum log level to output (`'silent'`, `'error'`, `'warn'`, `'info'`, `'debug'`) | +| `format` | string | `'json'` | Output format (`'json'` or `'simple'`) | +| `callerLevel` | string | `'warn'` | Minimum log level to include caller info (`'silent'`, `'error'`, `'warn'`, `'info'`, `'debug'`) | +| `colours` | object | See below | Color codes for each log level | +| `levels` | object | See below | Custom level names and numeric values | + +## Common Usage Patterns + +```js +import Logger from '@iankulin/logger'; + +// Production: JSON format with environment-based level +const prodLogger = new Logger({ + level: process.env.LOG_LEVEL || 'info', + format: 'json', + callerLevel: 'error', // Performance optimization +}); + +// Development: Simple format with debug level +const devLogger = new Logger({ + level: 'debug', + format: 'simple', +}); + +// Testing: Silent mode +const testLogger = new Logger({ level: 'silent' }); +``` + +## Requirements + +- Node.js 18.0.0 or higher +- ES modules support + +## License + +[MIT](LICENSE) + +## Versions + +- **0.1.5** - Initial release +- **0.1.6** - Added tests, improved error handling, caller detection loop prevention, `silent` logging level +- **1.0.0** - Production release +- **1.0.2** - Added types for intellisense +- **1.1.0** - added { time: 'short' } option, refactor tests, added { callerLevel: 'warn' } option +- **1.1.2** - dependencies update following [chalk supply chain attack](https://www.bleepingcomputer.com/news/security/self-propagating-supply-chain-attack-hits-187-npm-packages/) although not affected. diff --git a/demo.js b/demo.js new file mode 100644 index 0000000..59d3ac9 --- /dev/null +++ b/demo.js @@ -0,0 +1,94 @@ +import Logger from './lib/logger.ts'; +const logger = new Logger({ level: 'debug' }); + +logger.error('Unable to fetch student'); +logger.info('Hello from logger'); +logger.warn('This is a warning'); +logger.debug('This is a debug message'); // This won't be logged if level is set to 'info' +logger.level('error'); +logger.debug('This is a debug message'); // This won't be logged if level is set to 'info' or higher + +const simple_logger = new Logger({ level: 'debug', format: 'simple' }); + +simple_logger.error('Unable to fetch student'); +simple_logger.info('Hello from logger'); +simple_logger.warn('This is a warning'); +simple_logger.debug('This is a debug message'); // This won't be logged if level is set to 'info' +simple_logger.level('error'); +simple_logger.debug('This is a debug message'); // This won't be logged if level is set to 'info' or higher + +const longLogger = new Logger({ time: 'long', format: 'simple' }); +const shortLogger = new Logger({ time: 'short', format: 'simple' }); + +longLogger.info('This uses long time format'); +shortLogger.info('This uses short time format'); + +// Demonstrate callerLevel functionality +console.log('\n=== Caller Level Demo ==='); + +// Default callerLevel is 'warn' - only errors and warnings include caller info +const defaultCallerLogger = new Logger({ format: 'simple' }); +console.log( + 'Default callerLevel (warn) - only errors and warnings show caller info:' +); +defaultCallerLogger.error('Error with caller info'); +defaultCallerLogger.warn('Warning with caller info'); +defaultCallerLogger.info('Info without caller info'); +defaultCallerLogger.debug('Debug without caller info'); + +// Set callerLevel to 'error' - only errors include caller info +const errorOnlyLogger = new Logger({ format: 'simple', callerLevel: 'error' }); +console.log('\nCallerLevel set to error - only errors show caller info:'); +errorOnlyLogger.error('Error with caller info'); +errorOnlyLogger.warn('Warning without caller info'); +errorOnlyLogger.info('Info without caller info'); + +// Set callerLevel to 'debug' - all levels include caller info +const allLevelsLogger = new Logger({ format: 'simple', callerLevel: 'debug' }); +console.log('\nCallerLevel set to debug - all levels show caller info:'); +allLevelsLogger.error('Error with caller info'); +allLevelsLogger.warn('Warning with caller info'); +allLevelsLogger.info('Info with caller info'); +allLevelsLogger.debug('Debug with caller info'); + +// Set callerLevel to 'silent' - no levels include caller info +const noneLogger = new Logger({ format: 'simple', callerLevel: 'silent' }); +console.log('\nCallerLevel set to silent - no levels show caller info:'); +noneLogger.error('Error without caller info'); +noneLogger.warn('Warning without caller info'); +noneLogger.info('Info without caller info'); + +// Demonstrate format string functionality (util.format style) +console.log('\n=== Format String Demo ==='); + +const formatLogger = new Logger({ format: 'simple', level: 'debug' }); +console.log('Format strings with various specifiers:'); + +// String formatting (%s) +formatLogger.info('User %s logged in successfully', 'john_doe'); + +// Number formatting (%d, %i, %f) +formatLogger.warn('Database has %d connections, CPU usage: %f%%', 25, 84.3); +formatLogger.debug('Processing item %i of %d', 42, 100); + +// JSON formatting (%j) +const user = { name: 'Alice', role: 'admin', active: true }; +const config = { timeout: 5000, retries: 3 }; +formatLogger.info('User data: %j, Config: %j', user, config); + +// Mixed formatting +formatLogger.error('API call failed for user %s (ID: %d) with config %j', 'bob', 1234, config); + +// Multiple arguments without format specifiers +formatLogger.warn('System alert:', 'High memory usage detected', { usage: '89%', threshold: '80%' }); + +// Literal percentage with %% +formatLogger.info('Upload progress: 50%% complete'); + +// Edge cases +formatLogger.debug('Values: %s, %s, %d', null, undefined, null); + +console.log('\nJSON format with same messages:'); +const jsonFormatLogger = new Logger({ format: 'json' }); +jsonFormatLogger.info('User %s logged in with %d failed attempts', 'alice', 2); +jsonFormatLogger.warn('Config loaded: %j', { env: 'production', debug: false }); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..e6a117a --- /dev/null +++ b/deno.json @@ -0,0 +1,12 @@ +{ + "imports": { + "@std/assert": "https://deno.land/std@0.224.0/assert/mod.ts", + "@std/testing": "https://deno.land/std@0.224.0/testing/mock.ts" + }, + "tasks": { + "dev": "deno run --allow-env --allow-sys demo.js", + "test": "deno test --allow-env --allow-sys", + "lint": "deno lint", + "check": "deno check lib/logger.ts" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..6746a95 --- /dev/null +++ b/deno.lock @@ -0,0 +1,52 @@ +{ + "version": "5", + "specifiers": { + "npm:@types/node@*": "24.2.0" + }, + "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + } + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e" + } +} diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..5b715d4 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,483 @@ +// Native implementation of util.format functionality +function format(f: unknown, ...args: unknown[]): string { + if (typeof f !== 'string') { + // If first argument is not a string, just join all args with spaces + return [f, ...args].map(arg => { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'object') { + try { + return JSON.stringify(arg, null, 0); + } catch { + // Handle circular references and other JSON errors + return '[object Object]'; + } + } + return String(arg); + }).join(' '); + } + + let i = 0; + // First, handle %% replacement - if there are no args, %% stays as %% + // If there are args, %% becomes % + const handlePercentPercent = args.length === 0 ? '%%' : '%'; + + const str = f.replace(/%[sdifj%]/g, (match: string) => { + if (match === '%%') { + return handlePercentPercent; + } + + if (i >= args.length) return match; + + const arg = args[i++]; + + switch (match) { + case '%s': + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'object') { + try { + // For objects without %j, use a simplified string representation + if (Array.isArray(arg)) { + return `[ ${arg.join(', ')} ]`; + } + // For plain objects, show key-value pairs + const entries = Object.entries(arg).map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : v}`); + return `{ ${entries.join(', ')} }`; + } catch { + return '[object Object]'; + } + } + try { + return String(arg); + } catch { + return '[object Object]'; + } + + case '%d': + if (arg === null) return '0'; + if (arg === undefined) return 'NaN'; + return String(Number(arg)); + + case '%i': + if (arg === null) return '0'; + if (arg === undefined) return 'NaN'; + return String(parseInt(String(Number(arg)), 10)); + + case '%f': + if (arg === null) return '0'; + if (arg === undefined) return 'NaN'; + return String(parseFloat(String(Number(arg)))); + + case '%j': + try { + return JSON.stringify(arg); + } catch { + return '[Circular]'; + } + + default: + return match; + } + }); + + // Append any remaining arguments + const remainingArgs = args.slice(i); + if (remainingArgs.length > 0) { + return str + ' ' + remainingArgs.map(arg => { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'object') { + try { + if (Array.isArray(arg)) { + return `[ ${arg.join(', ')} ]`; + } + const entries = Object.entries(arg).map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : v}`); + return `{ ${entries.join(', ')} }`; + } catch { + return '[object Object]'; + } + } + return String(arg); + }).join(' '); + } + + return str; +} + +export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +export interface LogLevels { + [level: string]: number; + silent: -1; + error: 0; + warn: 1; + info: 2; + debug: 3; +} + +export interface Colours { + [level: string]: string; + error: string; + warn: string; + info: string; + debug: string; + reset: string; +} + +export interface LogEntry { + level: string; + levelNumber: number; + time: string; + pid: number; + hostname: string; + msg: string; + callerFile?: string; + callerLine?: number; + [key: string]: unknown; +} + +export type Formatter = (logEntry: LogEntry) => string; + +export interface LoggerOptions { + level?: LogLevel; + levels?: Partial; + format?: 'json' | 'simple'; + time?: 'long' | 'short'; + callerLevel?: LogLevel; + colours?: Partial; +} + +class Logger { + options: { + level: LogLevel; + levels: LogLevels; + format: 'json' | 'simple'; + time: 'long' | 'short'; + callerLevel: LogLevel; + colours: Colours; + }; + isRedirected: boolean; + formatters: { [key: string]: Formatter }; + callerErrorCount: number; + maxCallerErrors: number; + + constructor(options: LoggerOptions = {}) { + this.validateOptions(options); + + const defaultLevels: LogLevels = { + silent: -1, + error: 0, + warn: 1, + info: 2, + debug: 3, + }; + + const defaultColours: Colours = { + error: '\x1b[91m', + warn: '\x1b[33m', + info: '\x1b[94m', + debug: '\x1b[37m', + reset: '\x1b[0m', + }; + + this.options = { + level: options.level || 'info', + levels: Object.assign({}, defaultLevels, options.levels), + format: options.format || 'json', + time: options.time || 'short', + callerLevel: options.callerLevel || 'warn', + colours: Object.assign({}, defaultColours, options.colours), + }; + + // Detect if output is redirected to a file + this.isRedirected = !Deno.stdout.isTerminal(); + + // Initialize formatters registry + this.formatters = { + json: this.jsonFormatter.bind(this), + simple: this.simpleFormatter.bind(this), + }; + + // prevent infinite loop when reporting internal errors in getCallerInfo() + this.callerErrorCount = 0; + this.maxCallerErrors = 5; + } + + validateOptions(options: LoggerOptions): void { + // Validate level if provided + if (options.level !== undefined) { + const validLevels: LogLevel[] = ['silent', 'error', 'warn', 'info', 'debug']; + if (!validLevels.includes(options.level)) { + throw new Error( + `Invalid log level: ${ + options.level + }. Valid levels are: ${validLevels.join(', ')}` + ); + } + } + + // Validate format if provided + if (options.format !== undefined) { + const validFormats = ['json', 'simple']; + if (!validFormats.includes(options.format)) { + throw new Error( + `Invalid format: ${ + options.format + }. Valid formats are: ${validFormats.join(', ')}` + ); + } + } + + // Validate time if provided + if (options.time !== undefined) { + const validTimes = ['long', 'short']; + if (!validTimes.includes(options.time)) { + throw new Error( + `Invalid time: ${ + options.time + }. Valid times are: ${validTimes.join(', ')}` + ); + } + } + + // Validate callerLevel if provided + if (options.callerLevel !== undefined) { + const validLevels: LogLevel[] = ['silent', 'error', 'warn', 'info', 'debug']; + if (!validLevels.includes(options.callerLevel)) { + throw new Error( + `Invalid callerLevel: ${ + options.callerLevel + }. Valid levels are: ${validLevels.join(', ')}` + ); + } + } + + // Validate colours if provided (should be an object) + if (options.colours !== undefined && typeof options.colours !== 'object') { + throw new Error('colours option must be an object'); + } + + // Validate levels if provided (should be an object with numeric values) + if (options.levels !== undefined) { + if (typeof options.levels !== 'object') { + throw new Error('levels option must be an object'); + } + + for (const [level, value] of Object.entries(options.levels)) { + if ( + typeof value !== 'number' || + value < 0 || + !Number.isInteger(value) + ) { + throw new Error( + `Level value for '${level}' must be a non-negative integer` + ); + } + } + } + } + + // JSON log formatter + jsonFormatter(logEntry: LogEntry): string { + try { + return JSON.stringify(logEntry); + } catch (error) { + // Fallback for circular references or other JSON.stringify errors + try { + // Try to create a safe version by stringifying individual fields + const safeEntry = { + level: logEntry.level, + levelNumber: logEntry.levelNumber, + time: logEntry.time, + pid: logEntry.pid, + hostname: logEntry.hostname, + msg: String(logEntry.msg), // Convert to string safely + callerFile: logEntry.callerFile, + callerLine: logEntry.callerLine, + jsonError: `JSON stringify failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + return JSON.stringify(safeEntry); + } catch { + // Last resort - return a plain string + return `{"level":"${logEntry.level}","msg":"${String( + logEntry.msg + // eslint-disable-next-line no-useless-escape + ).replace(/"/g, '\"')}","jsonError":"Multiple JSON errors occurred"}`; + } + } + } + + // Simple text log formatter + simpleFormatter(logEntry: LogEntry): string { + const levelPadded = logEntry.level.toUpperCase().padEnd(5); + const caller = logEntry.callerFile + ? `${logEntry.callerFile.split('/').pop()}:${logEntry.callerLine}` + : null; + + return caller + ? `[${logEntry.time}] [${levelPadded}] [${caller}] ${logEntry.msg}` + : `[${logEntry.time}] [${levelPadded}] ${logEntry.msg}`; + } + + getCallerInfo(): { callerFile: string; callerLine: number } { + const originalFunc = (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace; + let callerFile = 'unknown'; + let callerLine = 0; + try { + const err = new Error(); + // deno-lint-ignore prefer-const + let currentFile: string | undefined; + (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace = function (_err: unknown, stack: unknown) { + return stack; + }; + const stack = err.stack as unknown as { shift: () => { getFileName: () => string; getLineNumber: () => number }; length: number }; + currentFile = stack.shift().getFileName(); + while (stack.length) { + const stackFrame = stack.shift(); + callerFile = stackFrame.getFileName(); + if (currentFile !== callerFile) { + callerLine = stackFrame.getLineNumber(); + break; + } + } + this.callerErrorCount = 0; + } catch (e) { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error('Error retrieving caller info:', e); + if (this.callerErrorCount === this.maxCallerErrors) { + // loop detected + console.error( + `Caller detection failed ${this.maxCallerErrors} times. Suppressing further caller error messages.` + ); + } + } + // callerFile and callerLine already set to defaults above + } finally { + (Error as unknown as { prepareStackTrace?: unknown }).prepareStackTrace = originalFunc; + } + return { callerFile, callerLine }; + } + + log(level: LogLevel, message: unknown, ...args: unknown[]): void { + if (this.options.levels[level] > this.options.levels[this.options.level]) { + return; + } + + // Only get caller info if current level is at or above callerLevel threshold + const shouldIncludeCaller = + this.options.levels[level] <= + this.options.levels[this.options.callerLevel]; + const { callerFile, callerLine } = shouldIncludeCaller + ? this.getCallerInfo() + : { callerFile: undefined, callerLine: undefined }; + + const now = new Date(); + const time = + this.options.time === 'long' + ? now.toISOString() + : now.toISOString().slice(0, 16).replace('T', ' '); + + const logEntry: LogEntry = { + level, + levelNumber: this.options.levels[level], + time: time, + pid: Deno.pid, + hostname: Deno.hostname(), + msg: format(message, ...args), + }; + + // Only include caller info if it was requested + if (shouldIncludeCaller && callerFile !== undefined && callerLine !== undefined) { + logEntry.callerFile = callerFile; + logEntry.callerLine = callerLine; + } + + const colour = this.options.colours[level]; + const resetColour = this.options.colours.reset; + + // Select the appropriate formatter + const formatter = + this.formatters[this.options.format] || this.formatters.json; + + let formattedLog: string; + try { + formattedLog = formatter(logEntry); + + // Ensure formatter returned a string + if (typeof formattedLog !== 'string') { + throw new Error( + `Formatter returned ${typeof formattedLog} instead of string` + ); + } + } catch (error) { + // Formatter failed, fall back to JSON formatter + try { + const safeEntry = { + ...logEntry, + formatterError: `Formatter failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + formattedLog = this.formatters.json(safeEntry); + } catch { + // Even JSON formatter failed, create minimal safe output + formattedLog = `{"level":"${logEntry.level}","msg":"${String( + logEntry.msg + ).replace( + /"/g, + // eslint-disable-next-line no-useless-escape + '\"' + )}","formatterError":"Formatter failed: ${error instanceof Error ? error.message : 'Unknown error'}"}`; + } + } + + // only show colours if logging to console + if (this.isRedirected) { + console.log(formattedLog); + } else { + console.log(`${colour}${formattedLog}${resetColour}`); + } + } + + error(message: unknown, ...args: unknown[]): void { + this.log('error', message, ...args); + } + + warn(message: unknown, ...args: unknown[]): void { + this.log('warn', message, ...args); + } + + info(message: unknown, ...args: unknown[]): void { + this.log('info', message, ...args); + } + + debug(message: unknown, ...args: unknown[]): void { + this.log('debug', message, ...args); + } + + level(): LogLevel; + level(newLevel: LogLevel): LogLevel; + level(newLevel?: LogLevel): LogLevel { + // If argument provided, set the new level + if (arguments.length > 0 && newLevel !== undefined) { + if (Object.hasOwn(this.options.levels, newLevel)) { + this.options.level = newLevel; + } else { + throw new Error(`Invalid log level: ${newLevel}`); + } + } + return this.options.level; + } + + setLevel(): LogLevel; + setLevel(newLevel: LogLevel): LogLevel; + setLevel(newLevel?: LogLevel): LogLevel { + if (arguments.length === 0) { + // Why are you using this as a getter? + return this.level(); + } + return this.level(newLevel!); + } +} + +export default Logger; \ No newline at end of file diff --git a/test/helpers/logger-test-helpers.js b/test/helpers/logger-test-helpers.js new file mode 100644 index 0000000..d175081 --- /dev/null +++ b/test/helpers/logger-test-helpers.js @@ -0,0 +1,80 @@ +// Shared test utilities for logger tests + +// Mock console.log to capture output +let capturedLogs = []; +let capturedErrors = []; +const originalConsoleLog = console.log; +const originalConsoleError = console.error; +const originalIsTTY = Deno.stdout.isTerminal(); + +export function mockConsole() { + console.log = (...args) => { + capturedLogs.push(args.join(' ')); + }; +} + +export function mockConsoleError() { + console.error = (...args) => { + capturedErrors.push(args); + }; +} + +export function restoreConsole() { + console.log = originalConsoleLog; + capturedLogs = []; +} + +export function restoreConsoleError() { + console.error = originalConsoleError; + capturedErrors = []; +} + +export function getCapturedLogs() { + return capturedLogs; +} + +export function getCapturedErrors() { + return capturedErrors; +} + +export function clearCapturedLogs() { + capturedLogs = []; +} + +export function clearCapturedErrors() { + capturedErrors = []; +} + +let mockIsTTY = originalIsTTY; + +export function setTTYMode(isTTY) { + mockIsTTY = isTTY; + // Mock Deno.stdout.isTerminal for testing + Deno.stdout.isTerminal = () => mockIsTTY; +} + +export function restoreTTY() { + mockIsTTY = originalIsTTY; + // Restore original Deno.stdout.isTerminal + Deno.stdout.isTerminal = () => originalIsTTY; +} + +// Helper to setup both console mocks +export function setupMocks() { + mockConsole(); + mockConsoleError(); +} + +// Helper to restore both console mocks +export function restoreMocks() { + restoreConsole(); + restoreConsoleError(); +} + +// Helper to get parsed JSON from first captured log +export function getFirstLogAsJSON() { + if (capturedLogs.length === 0) { + throw new Error('No logs captured'); + } + return JSON.parse(capturedLogs[0]); +} diff --git a/test/logger.caller-level.test.js b/test/logger.caller-level.test.js new file mode 100644 index 0000000..a2d3ea0 --- /dev/null +++ b/test/logger.caller-level.test.js @@ -0,0 +1,298 @@ +import { assertEquals, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; + +Deno.test("Logger callerLevel - Caller Information Filtering - should include caller info for error when callerLevel is warn", () => { + const logger = new Logger({ callerLevel: 'warn' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.error('test message'); + + // Parse JSON output and check for caller info + const logEntry = JSON.parse(capturedOutput); + assert( + logEntry.callerFile, + 'Should include callerFile for error level' + ); + assert( + typeof logEntry.callerLine === 'number', + 'Should include callerLine for error level' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Caller Information Filtering - should include caller info for warn when callerLevel is warn", () => { + const logger = new Logger({ callerLevel: 'warn' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.warn('test message'); + + // Parse JSON output and check for caller info + const logEntry = JSON.parse(capturedOutput); + assert( + logEntry.callerFile, + 'Should include callerFile for warn level' + ); + assert( + typeof logEntry.callerLine === 'number', + 'Should include callerLine for warn level' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Caller Information Filtering - should NOT include caller info for info when callerLevel is warn", () => { + const logger = new Logger({ callerLevel: 'warn' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.info('test message'); + + // Parse JSON output and check for absence of caller info + const logEntry = JSON.parse(capturedOutput); + assertEquals( + logEntry.callerFile, + undefined, + 'Should NOT include callerFile for info level' + ); + assertEquals( + logEntry.callerLine, + undefined, + 'Should NOT include callerLine for info level' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Caller Information Filtering - should NOT include caller info for debug when callerLevel is warn", () => { + const logger = new Logger({ callerLevel: 'warn', level: 'debug' }); // Set level to debug to ensure debug messages are logged + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.debug('test message'); + + // Parse JSON output and check for absence of caller info + const logEntry = JSON.parse(capturedOutput); + assertEquals( + logEntry.callerFile, + undefined, + 'Should NOT include callerFile for debug level' + ); + assertEquals( + logEntry.callerLine, + undefined, + 'Should NOT include callerLine for debug level' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Caller Information Filtering - should include caller info for all levels when callerLevel is debug", () => { + const logger = new Logger({ callerLevel: 'debug', level: 'debug' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + // Test each level + const levels = ['error', 'warn', 'info', 'debug']; + for (const level of levels) { + logger[level]('test message'); + + const logEntry = JSON.parse(capturedOutput); + assert( + logEntry.callerFile, + `Should include callerFile for ${level} level` + ); + assert( + typeof logEntry.callerLine === 'number', + `Should include callerLine for ${level} level` + ); + } + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Caller Information Filtering - should NOT include caller info for any level when callerLevel is silent", () => { + const logger = new Logger({ callerLevel: 'silent', level: 'debug' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + // Test each level + const levels = ['error', 'warn', 'info', 'debug']; + for (const level of levels) { + logger[level]('test message'); + + const logEntry = JSON.parse(capturedOutput); + assertEquals( + logEntry.callerFile, + undefined, + `Should NOT include callerFile for ${level} level` + ); + assertEquals( + logEntry.callerLine, + undefined, + `Should NOT include callerLine for ${level} level` + ); + } + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Simple Formatter with callerLevel - should format correctly without caller info when excluded", () => { + const logger = new Logger({ format: 'simple', callerLevel: 'error' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.info('test message'); + + // Should not contain caller info pattern + assert( + !capturedOutput.includes('unknown'), + 'Should not include caller placeholder' + ); + assert( + !capturedOutput.includes('.js:'), + 'Should not include file:line pattern' + ); + + // Should still contain other parts + assert(capturedOutput.includes('INFO'), 'Should include log level'); + assert( + capturedOutput.includes('test message'), + 'Should include message' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Simple Formatter with callerLevel - should format correctly with caller info when included", () => { + const logger = new Logger({ format: 'simple', callerLevel: 'info' }); + + // Mock console.log to capture output + const originalLog = console.log; + let capturedOutput = ''; + console.log = (message) => { + capturedOutput = message; + }; + + try { + logger.info('test message'); + + // Should contain caller info pattern + assert( + capturedOutput.includes('.js:'), + 'Should include file:line pattern' + ); + assert(capturedOutput.includes('INFO'), 'Should include log level'); + assert( + capturedOutput.includes('test message'), + 'Should include message' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Performance Considerations - should not call getCallerInfo when caller info is not needed", () => { + const logger = new Logger({ callerLevel: 'error' }); + + // Spy on getCallerInfo method + let getCallerInfoCalled = false; + const originalGetCallerInfo = logger.getCallerInfo; + logger.getCallerInfo = function () { + getCallerInfoCalled = true; + return originalGetCallerInfo.call(this); + }; + + // Mock console.log to prevent actual output + const originalLog = console.log; + console.log = () => {}; + + try { + logger.info('test message'); + assertEquals( + getCallerInfoCalled, + false, + 'getCallerInfo should not be called for info level when callerLevel is error' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger callerLevel - Performance Considerations - should call getCallerInfo when caller info is needed", () => { + const logger = new Logger({ callerLevel: 'warn' }); + + // Spy on getCallerInfo method + let getCallerInfoCalled = false; + const originalGetCallerInfo = logger.getCallerInfo; + logger.getCallerInfo = function () { + getCallerInfoCalled = true; + return originalGetCallerInfo.call(this); + }; + + // Mock console.log to prevent actual output + const originalLog = console.log; + console.log = () => {}; + + try { + logger.error('test message'); + assertEquals( + getCallerInfoCalled, + true, + 'getCallerInfo should be called for error level when callerLevel is warn' + ); + } finally { + console.log = originalLog; + } +}); \ No newline at end of file diff --git a/test/logger.constructor.test.js b/test/logger.constructor.test.js new file mode 100644 index 0000000..deb23aa --- /dev/null +++ b/test/logger.constructor.test.js @@ -0,0 +1,158 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import Logger from '../lib/logger.ts'; + +Deno.test("Logger Constructor - should throw error for invalid log level", () => { + assertThrows(() => { + new Logger({ level: 'invalid' }); + }, Error, "Invalid log level: invalid. Valid levels are: silent, error, warn, info, debug"); +}); + +Deno.test("Logger Constructor - should throw error for invalid format", () => { + assertThrows(() => { + new Logger({ format: 'invalid' }); + }, Error, "Invalid format: invalid. Valid formats are: json, simple"); +}); + +Deno.test("Logger Constructor - should throw error for invalid time option", () => { + assertThrows(() => { + new Logger({ time: 'invalid' }); + }, Error, "Invalid time: invalid. Valid times are: long, short"); +}); + +Deno.test("Logger Constructor - should throw error for invalid callerLevel", () => { + assertThrows(() => { + new Logger({ callerLevel: 'invalid' }); + }, Error, "Invalid callerLevel: invalid. Valid levels are: silent, error, warn, info, debug"); +}); + +Deno.test("Logger Constructor - should throw error for non-object colours", () => { + assertThrows(() => { + new Logger({ colours: 'not an object' }); + }, Error, "colours option must be an object"); +}); + +Deno.test("Logger Constructor - should throw error for non-object levels", () => { + assertThrows(() => { + new Logger({ levels: 'not an object' }); + }, Error, "levels option must be an object"); +}); + +Deno.test("Logger Constructor - should throw error for invalid level values", () => { + assertThrows(() => { + new Logger({ levels: { error: -1 } }); + }, Error, "Level value for 'error' must be a non-negative integer"); + + assertThrows(() => { + new Logger({ levels: { error: 'not a number' } }); + }, Error, "Level value for 'error' must be a non-negative integer"); + + assertThrows(() => { + new Logger({ levels: { error: 1.5 } }); + }, Error, "Level value for 'error' must be a non-negative integer"); +}); + +Deno.test("Logger Constructor - should accept valid options without throwing", () => { + // This should not throw + new Logger({ + level: 'debug', + format: 'simple', + time: 'long', + callerLevel: 'error', + colours: { error: '\x1b[31m' }, + levels: { custom: 4 }, + }); +}); + +Deno.test("Logger Constructor - should instantiate with default options", () => { + const logger = new Logger(); + assertEquals(logger.options.level, 'info'); + assertEquals(logger.options.format, 'json'); + assertEquals(logger.options.time, 'short'); + assertEquals(logger.options.callerLevel, 'warn'); + assertEquals(logger.options.levels, { + silent: -1, + error: 0, + warn: 1, + info: 2, + debug: 3, + }); +}); + +Deno.test("Logger Constructor - should instantiate with custom options", () => { + const logger = new Logger({ + level: 'debug', + format: 'simple', + time: 'long', + callerLevel: 'error', + }); + assertEquals(logger.options.level, 'debug'); + assertEquals(logger.options.format, 'simple'); + assertEquals(logger.options.time, 'long'); + assertEquals(logger.options.callerLevel, 'error'); +}); + +Deno.test("Logger Constructor - should merge options correctly", () => { + const customOptions = { + level: 'debug', + format: 'simple', + time: 'long', + colours: { + error: '\x1b[31m', // different red + }, + }; + + const logger = new Logger(customOptions); + + assertEquals(logger.options.level, 'debug'); + assertEquals(logger.options.format, 'simple'); + assertEquals(logger.options.time, 'long'); + assertEquals(logger.options.colours.error, '\x1b[31m'); + // Should still have other default colors + assertEquals(logger.options.colours.warn, '\x1b[33m'); +}); + +Deno.test("Logger Constructor - should have all log level methods", () => { + const logger = new Logger(); + assertEquals(typeof logger.error, 'function'); + assertEquals(typeof logger.warn, 'function'); + assertEquals(typeof logger.info, 'function'); + assertEquals(typeof logger.debug, 'function'); +}); + +Deno.test("Logger Constructor - should have level management methods", () => { + const logger = new Logger(); + assertEquals(typeof logger.level, 'function'); + assertEquals(typeof logger.setLevel, 'function'); +}); + +Deno.test("Logger Constructor - should detect TTY correctly", () => { + const originalIsTTY = Deno.stdout.isTerminal(); + + // Mock TTY mode + Deno.stdout.isTerminal = () => true; + const logger1 = new Logger(); + assertEquals(logger1.isRedirected, false); + + Deno.stdout.isTerminal = () => false; + const logger2 = new Logger(); + assertEquals(logger2.isRedirected, true); + + // Restore original + Deno.stdout.isTerminal = () => originalIsTTY; +}); + +Deno.test("Logger Constructor - should work with all existing constructor patterns", () => { + // No options - should not throw + new Logger(); + + // Partial options - should not throw + new Logger({ level: 'debug' }); + + // Full options (without time) - should not throw + new Logger({ + level: 'warn', + format: 'simple', + colours: { error: '\x1b[31m' }, + levels: { custom: 5 }, + }); +}); \ No newline at end of file diff --git a/test/logger.internal-errors.test.js b/test/logger.internal-errors.test.js new file mode 100644 index 0000000..97b2a54 --- /dev/null +++ b/test/logger.internal-errors.test.js @@ -0,0 +1,106 @@ +import { assertEquals, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Internal Error Handling - Formatter Error Handling - should fall back to JSON formatter when custom formatter throws", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + + // Replace the simple formatter with one that throws + logger.formatters.simple = function () { + throw new Error('Custom formatter error'); + }; + + logger.info('test message'); + + // Should still produce output using JSON formatter fallback + assertEquals(getCapturedLogs().length, 1); + + // Should be valid JSON (fallback to JSON formatter) + const logOutput = getCapturedLogs()[0]; + const parsed = JSON.parse(logOutput); + assertEquals(parsed.msg, 'test message'); + assert( + parsed.formatterError.includes( + 'Formatter failed: Custom formatter error' + ) + ); +}); + +Deno.test("Logger Internal Error Handling - Formatter Error Handling - should not crash when formatter returns non-string", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + + // Replace formatter with one that returns an object instead of string + logger.formatters.simple = function () { + return { notAString: true }; + }; + + logger.info('test message'); + + // Should still produce output (fallback should handle this) + assertEquals(getCapturedLogs().length, 1); + + // Should be valid JSON from fallback + const logOutput = getCapturedLogs()[0]; + const parsed = JSON.parse(logOutput); + assertEquals(parsed.msg, 'test message'); +}); + +Deno.test("Logger Internal Error Handling - Formatter Error Handling - should preserve original formatters after error", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + + // Temporarily break the formatter + const originalSimple = logger.formatters.simple; + logger.formatters.simple = function () { + throw new Error('Temporary error'); + }; + + logger.info('first message'); + + // Restore the formatter + logger.formatters.simple = originalSimple; + + logger.info('second message'); + + // First message should have used fallback, second should work normally + assertEquals(getCapturedLogs().length, 2); + + // First log should be JSON (fallback) + JSON.parse(getCapturedLogs()[0]); // This should not throw + + // Second log should be simple format + assert(getCapturedLogs()[1].includes('[INFO ]')); + assert(getCapturedLogs()[1].includes('second message')); +}); + +Deno.test("Logger Internal Error Handling - Formatter Error Handling - should handle repeated formatter failures without memory leaks", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + + // Break the formatter + logger.formatters.simple = function () { + throw new Error('Always fails'); + }; + + // Log many times + for (let i = 0; i < 100; i++) { + logger.info(`message ${i}`); + } + + // Should have produced 100 fallback logs + assertEquals(getCapturedLogs().length, 100); + + // All should be valid JSON (fallback format) + getCapturedLogs().forEach((log) => { + JSON.parse(log); // This should not throw + }); +}); \ No newline at end of file diff --git a/test/logger.internal-fallback.test.js b/test/logger.internal-fallback.test.js new file mode 100644 index 0000000..5122e8c --- /dev/null +++ b/test/logger.internal-fallback.test.js @@ -0,0 +1,368 @@ +import { assertEquals, assertThrows, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, + getCapturedErrors, + clearCapturedErrors, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Additional Fallback Tests - JSON Formatter Error Handling - should handle circular references in log data", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Create circular reference + const obj = { name: 'test' }; + obj.self = obj; + + logger.info('Message with circular ref: %j', obj); + + assertEquals(getCapturedLogs().length, 1); + const logOutput = getCapturedLogs()[0]; + + // Should be valid JSON despite circular reference + const parsed = JSON.parse(logOutput); + assertEquals(parsed.level, 'info'); + // Check for either jsonError or that the message was logged successfully + assert( + parsed.jsonError?.includes('JSON stringify failed') || + parsed.msg.includes('Message with circular ref') + ); +}); + +Deno.test("Logger Additional Fallback Tests - JSON Formatter Error Handling - should handle objects with non-serializable properties", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Create object with function (non-serializable) + const objWithFunction = { + name: 'test', + func: function () { + return 'hello'; + }, + symbol: Symbol('test'), + undefined: undefined, + }; + + logger.info('Object: %j', objWithFunction); + + assertEquals(getCapturedLogs().length, 1); + const logOutput = getCapturedLogs()[0]; + + // Should produce valid JSON + const parsed = JSON.parse(logOutput); + assertEquals(parsed.level, 'info'); + // Should have the message in some form + assert(parsed.msg.includes('Object:')); +}); + +Deno.test("Logger Additional Fallback Tests - JSON Formatter Error Handling - should handle when JSON formatter itself is broken", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Break the JSON formatter + logger.formatters.json = function () { + throw new Error('JSON formatter is broken'); + }; + + logger.info('test message'); + + // Should still produce some output (last resort fallback) + assertEquals(getCapturedLogs().length, 1); + const logOutput = getCapturedLogs()[0]; + + // Should contain the message even if not perfectly formatted + assert(logOutput.includes('test message')); +}); + +Deno.test("Logger Additional Fallback Tests - Caller Detection Error Handling - should handle stack manipulation errors", () => { + clearCapturedLogs(); + clearCapturedErrors(); + + const logger = new Logger({ format: 'json', callerLevel: 'info' }); + + // Override getCallerInfo to simulate an error + const originalGetCallerInfo = logger.getCallerInfo; + logger.getCallerInfo = function () { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error( + 'Error retrieving caller info:', + new Error('Simulated caller error') + ); + if (this.callerErrorCount === this.maxCallerErrors) { + console.error( + `Caller detection failed ${this.maxCallerErrors} times. Suppressing further caller error messages.` + ); + } + } + return { callerFile: 'unknown', callerLine: 0 }; + }; + + try { + logger.info('test with simulated caller error'); + + // Should still log the message + assertEquals(getCapturedLogs().length, 1); + const parsed = JSON.parse(getCapturedLogs()[0]); + assertEquals(parsed.msg, 'test with simulated caller error'); + assertEquals(parsed.callerFile, 'unknown'); + assertEquals(parsed.callerLine, 0); + + // Should have logged an error about caller detection + assert(getCapturedErrors().length > 0); + } finally { + logger.getCallerInfo = originalGetCallerInfo; + logger.callerErrorCount = 0; + } +}); + +Deno.test("Logger Additional Fallback Tests - Caller Detection Error Handling - should suppress caller errors after max threshold", () => { + clearCapturedLogs(); + clearCapturedErrors(); + + const logger = new Logger({ format: 'json', callerLevel: 'info' }); + + // Override getCallerInfo to always simulate errors + const originalGetCallerInfo = logger.getCallerInfo; + logger.getCallerInfo = function () { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error( + 'Error retrieving caller info:', + new Error('Always fails') + ); + if (this.callerErrorCount === this.maxCallerErrors) { + console.error( + `Caller detection failed ${this.maxCallerErrors} times. Suppressing further caller error messages.` + ); + } + } + return { callerFile: 'unknown', callerLine: 0 }; + }; + + try { + // Log more than maxCallerErrors (5) times + for (let i = 0; i < 10; i++) { + logger.info(`test message ${i}`); + } + + // Should have logged all 10 messages + assertEquals(getCapturedLogs().length, 10); + + // Should have logged errors for first 5 attempts, then suppression message + const errorLogs = getCapturedErrors(); + assert(errorLogs.length >= 5); // At least 5 error calls + assert(errorLogs.length <= 6); // But not more than 6 (5 + suppression message) + + // Check that suppression message is included + const suppressionFound = errorLogs.some((errorArgs) => + errorArgs.some( + (arg) => + typeof arg === 'string' && + arg.includes('Suppressing further caller error messages') + ) + ); + assert(suppressionFound); + } finally { + logger.getCallerInfo = originalGetCallerInfo; + logger.callerErrorCount = 0; + } +}); + +Deno.test("Logger Additional Fallback Tests - Caller Detection Error Handling - should reset caller error count after successful detection", () => { + clearCapturedLogs(); + clearCapturedErrors(); + + const logger = new Logger({ format: 'json', callerLevel: 'info' }); + + // Override getCallerInfo to simulate different phases + const originalGetCallerInfo = logger.getCallerInfo; + let phase = 'error1'; + + logger.getCallerInfo = function () { + if (phase === 'error1') { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error( + 'Error retrieving caller info:', + new Error('Phase 1 error') + ); + } + return { callerFile: 'unknown', callerLine: 0 }; + } else if (phase === 'working') { + // Reset error count on successful call + this.callerErrorCount = 0; + return originalGetCallerInfo.call(this); + } else if (phase === 'error2') { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error( + 'Error retrieving caller info:', + new Error('Phase 2 error') + ); + } + return { callerFile: 'unknown', callerLine: 0 }; + } + }; + + try { + // Cause some errors + logger.info('test 1'); + logger.info('test 2'); + + // Switch to working mode + phase = 'working'; + logger.info('test 3'); + + // Break it again + phase = 'error2'; + logger.info('test 4'); + + const errorLogs = getCapturedErrors(); + // Should have errors from both phases + assert(errorLogs.length >= 3); + } finally { + logger.getCallerInfo = originalGetCallerInfo; + logger.callerErrorCount = 0; + } +}); + +Deno.test("Logger Additional Fallback Tests - Extreme Error Conditions - should handle when console.log itself throws", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Break console.log + const originalLog = console.log; + console.log = function () { + throw new Error('Console is broken'); + }; + + try { + // This should not crash the process, but the error will bubble up + // since there's no try-catch around console.log in the logger + assertThrows(() => { + logger.info('test message'); + }, Error, "Console is broken"); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger Additional Fallback Tests - Extreme Error Conditions - should handle when util.format has issues with complex objects", () => { + setupMocks(); + const logger = new Logger({ format: 'json' }); + + // Create an object that will cause issues with string conversion + const problematicObject = { + toString: function () { + throw new Error('toString failed'); + }, + valueOf: function () { + throw new Error('valueOf failed'); + }, + }; + + // The logger should handle this gracefully and not throw + logger.info('Message: %s', problematicObject); + + const logs = getCapturedLogs(); + assertEquals(logs.length, 1); + + const output = JSON.parse(logs[0]); + // The logger should handle the problematic object gracefully + // Either by showing [object Object] or the actual object structure + assertEquals(typeof output.msg, 'string'); + assertEquals(output.msg.startsWith('Message: '), true); + +}); + +Deno.test("Logger Additional Fallback Tests - Extreme Error Conditions - should handle when hostname fails", () => { + setupMocks(); + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Mock hostname retrieval to throw (this would need to be mocked at the source) + // Since we can't directly mock os.hostname in Deno, this test shows the concept + // In practice, hostname failures are rare but could happen + + // For Deno, we could mock the hostname call if it were extracted to a mockable function + // For now, this is more of a documentation test + logger.info('test message'); + assertEquals(getCapturedLogs().length, 1); + +}); + +Deno.test("Logger Additional Fallback Tests - Fallback Chain Testing - should handle formatter failures gracefully", () => { + setupMocks(); + clearCapturedLogs(); + clearCapturedErrors(); + + const logger = new Logger({ format: 'simple', callerLevel: 'info' }); + + // Break the simple formatter + logger.formatters.simple = function () { + throw new Error('Simple formatter broken'); + }; + + // Also simulate caller detection failure + const originalGetCallerInfo = logger.getCallerInfo; + logger.getCallerInfo = function () { + this.callerErrorCount++; + if (this.callerErrorCount <= this.maxCallerErrors) { + console.error( + 'Error retrieving caller info:', + new Error('Caller detection failed') + ); + } + return { callerFile: 'unknown', callerLine: 0 }; + }; + + try { + // Should still produce some output despite multiple failures + logger.info('test message'); + + // Should produce some kind of output (fallback to JSON formatter) + assertEquals(getCapturedLogs().length, 1); + const output = getCapturedLogs()[0]; + + // Should be valid JSON (fallback formatter) + const parsed = JSON.parse(output); + assertEquals(parsed.msg, 'test message'); + assert(parsed.formatterError.includes('Simple formatter broken')); + assertEquals(parsed.callerFile, 'unknown'); + } finally { + logger.getCallerInfo = originalGetCallerInfo; + logger.callerErrorCount = 0; + } +}); + +Deno.test("Logger Additional Fallback Tests - Resource Cleanup - should not leak memory during repeated errors", () => { + setupMocks(); + clearCapturedLogs(); + clearCapturedErrors(); + + const logger = new Logger({ format: 'simple' }); + + // Break the formatter + logger.formatters.simple = function () { + throw new Error('Always fails'); + }; + + // Log many times to check for memory leaks + for (let i = 0; i < 1000; i++) { + logger.info(`message ${i}`); + } + + // Should have produced all logs + assertEquals(getCapturedLogs().length, 1000); + + // Check that we're not accumulating error state + // (This is more of a smoke test - real memory leak detection would need different tools) + assert(true); // If we get here without crashing, that's good + +}); \ No newline at end of file diff --git a/test/logger.json-formatter.test.js b/test/logger.json-formatter.test.js new file mode 100644 index 0000000..2be0958 --- /dev/null +++ b/test/logger.json-formatter.test.js @@ -0,0 +1,225 @@ +import { assertEquals, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, + getFirstLogAsJSON, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger JSON Formatter - Basic JSON Output - should produce valid JSON output", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info('test message'); + + assertEquals(getCapturedLogs().length, 1); + const logOutput = getCapturedLogs()[0]; + + // Should be valid JSON + JSON.parse(logOutput); +}); + +Deno.test("Logger JSON Formatter - Basic JSON Output - should include all required fields in JSON output", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json', callerLevel: 'info' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + assertEquals(parsed.level, 'info'); + assertEquals(parsed.levelNumber, 2); + assertEquals(parsed.msg, 'test message'); + assertEquals(typeof parsed.time, 'string'); + assertEquals(typeof parsed.pid, 'number'); + assertEquals(typeof parsed.hostname, 'string'); + assert(parsed.callerFile); + assertEquals(typeof parsed.callerLine, 'number'); +}); + +Deno.test("Logger JSON Formatter - Basic JSON Output - should format timestamp correctly based on time option", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json', time: 'short' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + // Should be short format, not ISO + assert(parsed.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); + assert(!parsed.time.includes('T')); + assert(!parsed.time.includes('Z')); +}); + +Deno.test("Logger JSON Formatter - JSON Error Handling - should handle circular references in log entry", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Create a circular reference by modifying the logger's formatters + const originalJsonFormatter = logger.formatters.json; + logger.formatters.json = function (logEntry) { + // Add a circular reference to the logEntry + const circular = { self: null }; + circular.self = circular; + logEntry.circular = circular; + + // Call the original formatter which should handle the error + return originalJsonFormatter.call(this, logEntry); + }; + + logger.info('test with circular reference'); + + const logOutput = getCapturedLogs()[0]; + // Should be valid JSON despite circular reference + const parsed = JSON.parse(logOutput); + // Should contain error information + assert(parsed.jsonError.includes('JSON stringify failed')); + assertEquals(parsed.msg, 'test with circular reference'); +}); + +Deno.test("Logger JSON Formatter - JSON Error Handling - should handle JSON stringify errors with fallback", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Create a problematic object that will cause JSON.stringify to fail + const problematic = {}; + Object.defineProperty(problematic, 'badProp', { + get() { + throw new Error('Property access error'); + }, + enumerable: true, + }); + + // Test the formatter directly with a problematic object + const problematicLogEntry = { + level: 'info', + msg: 'test message', + problematic: problematic, + }; + + const result = logger.formatters.json(problematicLogEntry); + + // Should produce valid JSON with error info + const parsed = JSON.parse(result); + assert(parsed.jsonError.includes('JSON stringify failed')); +}); + +Deno.test("Logger JSON Formatter - JSON Error Handling - should handle extreme JSON stringify failures", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // Create an object that will fail even the safe fallback + // by mocking JSON.stringify to always throw + const originalStringify = JSON.stringify; + let callCount = 0; + + JSON.stringify = function (...args) { + callCount++; + if (callCount <= 2) { + throw new Error('Mock JSON error'); + } + return originalStringify.apply(this, args); + }; + + try { + const result = logger.formatters.json({ + level: 'error', + msg: 'test message', + }); + + // Should still produce valid JSON string even after multiple failures + const parsed = JSON.parse(result); + assertEquals(parsed.level, 'error'); + assertEquals(parsed.msg, 'test message'); + assert(parsed.jsonError.includes('Multiple JSON errors occurred')); + } finally { + JSON.stringify = originalStringify; + } +}); + +Deno.test("Logger JSON Formatter - Special Characters and Edge Cases - should handle special characters", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info('Special chars: "quotes", \\backslash, \nnewline'); + + // Should produce valid JSON despite special characters + JSON.parse(getCapturedLogs()[0]); +}); + +Deno.test("Logger JSON Formatter - Special Characters and Edge Cases - should handle empty messages", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info(''); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.msg, ''); +}); + +Deno.test("Logger JSON Formatter - Special Characters and Edge Cases - should handle null and undefined arguments", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info('Value: %s', null); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.msg, 'Value: null'); +}); + +Deno.test("Logger JSON Formatter - Special Characters and Edge Cases - should handle very long messages", () => { + clearCapturedLogs(); + const longMessage = 'x'.repeat(10000); + const logger = new Logger({ format: 'json' }); + logger.info(longMessage); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.msg, longMessage); +}); + +Deno.test("Logger JSON Formatter - Special Characters and Edge Cases - should handle objects in messages", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + const obj = { key: 'value', nested: { prop: 123 } }; + logger.info('Object: %j', obj); + + const parsed = getFirstLogAsJSON(); + assert(parsed.msg.includes('{"key":"value","nested":{"prop":123}}')); +}); + +Deno.test("Logger JSON Formatter - All Log Levels in JSON - should log error messages with correct level", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.error('error message'); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.level, 'error'); + assertEquals(parsed.levelNumber, 0); +}); + +Deno.test("Logger JSON Formatter - All Log Levels in JSON - should log warn messages with correct level", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.warn('warn message'); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.level, 'warn'); + assertEquals(parsed.levelNumber, 1); +}); + +Deno.test("Logger JSON Formatter - All Log Levels in JSON - should log info messages with correct level", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info('info message'); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.level, 'info'); + assertEquals(parsed.levelNumber, 2); +}); + +Deno.test("Logger JSON Formatter - All Log Levels in JSON - should log debug messages with correct level", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'debug', format: 'json' }); + logger.debug('debug message'); + + const parsed = getFirstLogAsJSON(); + assertEquals(parsed.level, 'debug'); + assertEquals(parsed.levelNumber, 3); +}); \ No newline at end of file diff --git a/test/logger.level-management.test.js b/test/logger.level-management.test.js new file mode 100644 index 0000000..e7b635c --- /dev/null +++ b/test/logger.level-management.test.js @@ -0,0 +1,205 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Level Management - Level Setting and Getting - should change log level with level() method", () => { + const logger = new Logger(); + logger.level('debug'); + assertEquals(logger.options.level, 'debug'); +}); + +Deno.test("Logger Level Management - Level Setting and Getting - should return current level when called without arguments", () => { + const logger = new Logger({ level: 'debug' }); + assertEquals(logger.level(), 'debug'); +}); + +Deno.test("Logger Level Management - Level Setting and Getting - should return new level when setting level", () => { + const logger = new Logger(); + const result = logger.level('error'); + assertEquals(result, 'error'); + assertEquals(logger.options.level, 'error'); +}); + +Deno.test("Logger Level Management - Level Setting and Getting - should throw error for invalid log level", () => { + const logger = new Logger(); + assertThrows(() => { + logger.level('invalid'); + }, Error, "Invalid log level: invalid"); +}); + +Deno.test("Logger Level Management - Level Setting and Getting - should allow method chaining after setting level", () => { + const logger = new Logger(); + // This should not throw and should return a level + const result = logger.level('warn'); + assertEquals(result, 'warn'); + assertEquals(typeof result, 'string'); +}); + +Deno.test("Logger Level Management - setLevel Method - should have setLevel method as alias", () => { + const logger = new Logger(); + assertEquals(typeof logger.setLevel, 'function'); +}); + +Deno.test("Logger Level Management - setLevel Method - should set level correctly with setLevel method", () => { + const logger = new Logger(); + const result = logger.setLevel('debug'); + assertEquals(result, 'debug'); + assertEquals(logger.options.level, 'debug'); +}); + +Deno.test("Logger Level Management - setLevel Method - should return current level with setLevel when no args", () => { + const logger = new Logger({ level: 'warn' }); + const result = logger.setLevel(); + assertEquals(result, 'warn'); +}); + +Deno.test("Logger Level Management - setLevel Method - should throw error for invalid level in setLevel", () => { + const logger = new Logger(); + assertThrows(() => { + logger.setLevel('invalid'); + }, Error, "Invalid log level: invalid"); +}); + +Deno.test("Logger Level Management - setLevel Method - should maintain consistency between level() and setLevel()", () => { + const logger = new Logger(); + + logger.level('error'); + assertEquals(logger.setLevel(), 'error'); + + logger.setLevel('debug'); + assertEquals(logger.level(), 'debug'); +}); + +Deno.test("Logger Level Management - setLevel Method - should support fluent interface pattern", () => { + const logger = new Logger(); + + // This demonstrates the fluent interface working + const currentLevel = logger.level('warn'); + assertEquals(currentLevel, 'warn'); + + // Both methods should return the current level for chaining + assertEquals(logger.level('info'), 'info'); + assertEquals(logger.setLevel('debug'), 'debug'); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should filter debug messages when level is info", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'info' }); + logger.debug('debug message'); + assertEquals(getCapturedLogs().length, 0); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should show info messages when level is info", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'info' }); + logger.info('info message'); + assertEquals(getCapturedLogs().length, 1); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should show error messages at any level", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'error' }); + logger.error('error message'); + assertEquals(getCapturedLogs().length, 1); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should filter warn and info when level is error", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'error' }); + logger.warn('warn message'); + logger.info('info message'); + assertEquals(getCapturedLogs().length, 0); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should show all messages when level is debug", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'debug' }); + logger.error('error message'); + logger.warn('warn message'); + logger.info('info message'); + logger.debug('debug message'); + assertEquals(getCapturedLogs().length, 4); +}); + +Deno.test("Logger Level Management - Log Level Filtering - should show warn and above when level is warn", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'warn' }); + logger.error('error message'); + logger.warn('warn message'); + logger.info('info message'); + logger.debug('debug message'); + assertEquals(getCapturedLogs().length, 2); +}); + +Deno.test("Logger Level Management - Silent Level - should suppress all output when level is silent", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'silent' }); + + logger.error('error message'); + logger.warn('warn message'); + logger.info('info message'); + logger.debug('debug message'); + + // No messages should be logged + assertEquals(getCapturedLogs().length, 0); +}); + +Deno.test("Logger Level Management - Silent Level - should allow setting level to silent", () => { + const logger = new Logger(); + const result = logger.level('silent'); + assertEquals(result, 'silent'); + assertEquals(logger.options.level, 'silent'); +}); + +Deno.test("Logger Level Management - Silent Level - should work with setLevel for silent level", () => { + const logger = new Logger(); + const result = logger.setLevel('silent'); + assertEquals(result, 'silent'); + assertEquals(logger.options.level, 'silent'); +}); + +Deno.test("Logger Level Management - Silent Level - should remain silent after multiple log attempts", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'silent' }); + + // Try logging multiple times + for (let i = 0; i < 5; i++) { + logger.error(`error ${i}`); + logger.warn(`warn ${i}`); + logger.info(`info ${i}`); + logger.debug(`debug ${i}`); + } + + // Still no output + assertEquals(getCapturedLogs().length, 0); +}); + +Deno.test("Logger Level Management - Dynamic Level Changes - should respect level changes during runtime", () => { + clearCapturedLogs(); + const logger = new Logger({ level: 'error' }); + + // Should not log at info level + logger.info('info message 1'); + assertEquals(getCapturedLogs().length, 0); + + // Change to info level + logger.level('info'); + + // Should now log info messages + logger.info('info message 2'); + assertEquals(getCapturedLogs().length, 1); + + // Change to silent + logger.level('silent'); + + // Should not log anything + logger.error('error message'); + assertEquals(getCapturedLogs().length, 1); // Still just the previous info message +}); diff --git a/test/logger.robustness.test.js b/test/logger.robustness.test.js new file mode 100644 index 0000000..bf1de9f --- /dev/null +++ b/test/logger.robustness.test.js @@ -0,0 +1,146 @@ +import { assertEquals, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Robustness - Edge Cases and Data Handling - should not crash on logging errors", () => { + clearCapturedLogs(); + const logger = new Logger(); + + // This should not throw + logger.info('test message'); +}); + +Deno.test("Logger Robustness - Edge Cases and Data Handling - should handle undefined and null messages gracefully", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + // These should not crash + logger.info(undefined); + logger.info(null); + + const logs = getCapturedLogs(); + assertEquals(logs.length, 2); + + const parsed1 = JSON.parse(logs[0]); + const parsed2 = JSON.parse(logs[1]); + + assertEquals(parsed1.msg, 'undefined'); + assertEquals(parsed2.msg, 'null'); +}); + +Deno.test("Logger Robustness - Edge Cases and Data Handling - should handle extremely large messages", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + const hugeMessage = 'x'.repeat(100000); + + logger.info(hugeMessage); + + const parsed = JSON.parse(getCapturedLogs()[0]); + assertEquals(parsed.msg, hugeMessage); +}); + +Deno.test("Logger Robustness - Edge Cases and Data Handling - should handle circular objects in message formatting", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + const circular = { name: 'test' }; + circular.self = circular; + + logger.info('Circular: %j', circular); + + // Should still log something + assertEquals(getCapturedLogs().length, 1); +}); + +Deno.test("Logger Robustness - Performance and Memory - should handle rapid consecutive logging without issues", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + for (let i = 0; i < 1000; i++) { + logger.info(`rapid message ${i}`); + } + + assertEquals(getCapturedLogs().length, 1000); +}); + +Deno.test("Logger Robustness - Performance and Memory - should handle repeated logging operations efficiently", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + + const startTime = Date.now(); + + // Log a reasonable number of messages + for (let i = 0; i < 500; i++) { + logger.info(`performance test message ${i}`); + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete within reasonable time (adjust threshold as needed) + assert(duration < 5000, `Logging took too long: ${duration}ms`); + assertEquals(getCapturedLogs().length, 500); +}); + +Deno.test("Logger Robustness - Performance and Memory - should handle mixed format types in rapid succession", () => { + clearCapturedLogs(); + const jsonLogger = new Logger({ format: 'json' }); + const simpleLogger = new Logger({ format: 'simple' }); + + for (let i = 0; i < 50; i++) { + jsonLogger.info(`json message ${i}`); + simpleLogger.info(`simple message ${i}`); + } + + assertEquals(getCapturedLogs().length, 100); +}); + +Deno.test("Logger Robustness - Complex Data Structures - should handle deeply nested objects", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + const deepObject = { + level1: { level2: { level3: { level4: { value: 'deep' } } } }, + }; + + logger.info('Deep object: %j', deepObject); + + assertEquals(getCapturedLogs().length, 1); +}); + +Deno.test("Logger Robustness - Complex Data Structures - should handle arrays with mixed data types", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + const mixedArray = [ + 1, + 'string', + { obj: true }, + [1, 2, 3], + null, + undefined, + ]; + + logger.info('Mixed array: %j', mixedArray); + + assertEquals(getCapturedLogs().length, 1); +}); + +Deno.test("Logger Robustness - Complex Data Structures - should handle special characters and unicode", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + + const specialMessage = 'Special chars: \n\t\r\\"\'๐Ÿš€ Unicode: ใ“ใ‚“ใซใกใฏ'; + + logger.info(specialMessage); + + const parsed = JSON.parse(getCapturedLogs()[0]); + assertEquals(parsed.msg, specialMessage); +}); diff --git a/test/logger.simple-formatter.test.js b/test/logger.simple-formatter.test.js new file mode 100644 index 0000000..914883c --- /dev/null +++ b/test/logger.simple-formatter.test.js @@ -0,0 +1,217 @@ +import { assertEquals, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, + setTTYMode, + restoreTTY, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Simple Formatter - Basic Simple Format - should produce simple text format", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.info('test message'); + + assertEquals(getCapturedLogs().length, 1); + const logOutput = getCapturedLogs()[0]; + + // Should contain timestamp, level, caller, and message + assert(logOutput.includes('[INFO ]')); + assert(logOutput.includes('test message')); + // Should contain short timestamp by default + assert(logOutput.match(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]/)); +}); + +Deno.test("Logger Simple Formatter - Basic Simple Format - should pad log levels correctly", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple', level: 'debug' }); + logger.error('error msg'); + logger.debug('debug msg'); + + const logs = getCapturedLogs(); + assert(logs[0].includes('[ERROR]')); + assert(logs[1].includes('[DEBUG]')); +}); + +Deno.test("Logger Simple Formatter - Basic Simple Format - should include caller information", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple', callerLevel: 'info' }); + logger.info('test message'); + + const logOutput = getCapturedLogs()[0]; + // Should contain filename and line number + assert(logOutput.includes('.js:')); +}); + +Deno.test("Logger Simple Formatter - Basic Simple Format - should format with long timestamp when specified", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple', time: 'long' }); + logger.info('test message'); + + const logOutput = getCapturedLogs()[0]; + + // Should contain long time format in brackets + assert( + logOutput.match(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/) + ); + assert(logOutput.includes('T')); + assert(logOutput.includes('Z')); +}); + +Deno.test("Logger Simple Formatter - All Log Levels in Simple Format - should format error level correctly", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.error('error message'); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('[ERROR]')); + assert(logOutput.includes('error message')); +}); + +Deno.test("Logger Simple Formatter - All Log Levels in Simple Format - should format warn level correctly", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.warn('warn message'); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('[WARN ]')); + assert(logOutput.includes('warn message')); +}); + +Deno.test("Logger Simple Formatter - All Log Levels in Simple Format - should format info level correctly", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.info('info message'); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('[INFO ]')); + assert(logOutput.includes('info message')); +}); + +Deno.test("Logger Simple Formatter - All Log Levels in Simple Format - should format debug level correctly", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple', level: 'debug' }); + logger.debug('debug message'); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('[DEBUG]')); + assert(logOutput.includes('debug message')); +}); + +Deno.test("Logger Simple Formatter - Color Handling - should include color codes when output is TTY", () => { + clearCapturedLogs(); + setTTYMode(true); + const logger = new Logger({ format: 'simple' }); + logger.error('error message'); + + const logOutput = getCapturedLogs()[0]; + // Should contain ANSI color codes + assert(logOutput.includes('\x1b[91m')); // red for error + assert(logOutput.includes('\x1b[0m')); // reset +}); + +Deno.test("Logger Simple Formatter - Color Handling - should not include color codes when output is redirected", () => { + clearCapturedLogs(); + setTTYMode(false); + const logger = new Logger({ format: 'simple' }); + logger.error('error message'); + + const logOutput = getCapturedLogs()[0]; + // Should not contain ANSI color codes + assert(!logOutput.includes('\x1b[')); +}); + +Deno.test("Logger Simple Formatter - Color Handling - should use appropriate colors for different levels", () => { + clearCapturedLogs(); + setTTYMode(true); + const logger = new Logger({ format: 'simple', level: 'debug' }); + + logger.error('error'); + logger.warn('warn'); + logger.info('info'); + logger.debug('debug'); + + const logs = getCapturedLogs(); + + // Error should be red + assert(logs[0].includes('\x1b[91m')); + // Warn should be yellow + assert(logs[1].includes('\x1b[33m')); + // Info and debug might have different or no colors, but should have reset codes + assert(logs[2].includes('\x1b[0m')); + assert(logs[3].includes('\x1b[0m')); +}); + +Deno.test("Logger Simple Formatter - Color Handling - should respect custom color configuration", () => { + clearCapturedLogs(); + setTTYMode(true); + const logger = new Logger({ + format: 'simple', + colours: { + error: '\x1b[31m', // different red + warn: '\x1b[35m', // magenta instead of yellow + }, + }); + + logger.error('error message'); + logger.warn('warn message'); + + const logs = getCapturedLogs(); + assert(logs[0].includes('\x1b[31m')); + assert(logs[1].includes('\x1b[35m')); +}); + +Deno.test("Logger Simple Formatter - Message Formatting in Simple Mode - should handle multiple arguments", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.info('Hello %s, you are %d years old', 'John', 25); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('Hello John, you are 25 years old')); +}); + +Deno.test("Logger Simple Formatter - Message Formatting in Simple Mode - should handle special characters", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.info('Special chars: "quotes", \\backslash, \nnewline'); + + const logOutput = getCapturedLogs()[0]; + assert(logOutput.includes('Special chars: "quotes"')); +}); + +Deno.test("Logger Simple Formatter - Message Formatting in Simple Mode - should handle empty messages", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.info(''); + + const logOutput = getCapturedLogs()[0]; + // Should still have the level and timestamp parts + assert(logOutput.includes('[INFO ]')); +}); + +Deno.test("Logger Simple Formatter - TTY Detection Integration - should detect TTY mode changes correctly", () => { + clearCapturedLogs(); + + // Test with TTY + setTTYMode(true); + const ttyLogger = new Logger({ format: 'simple' }); + ttyLogger.error('tty error'); + + // Test without TTY + setTTYMode(false); + const noTtyLogger = new Logger({ format: 'simple' }); + noTtyLogger.error('no tty error'); + + const logs = getCapturedLogs(); + + // First should have colors, second should not + assert(logs[0].includes('\x1b[')); + assert(!logs[1].includes('\x1b[')); +}); + +// Cleanup +restoreTTY(); \ No newline at end of file diff --git a/test/logger.time-formatting.test.js b/test/logger.time-formatting.test.js new file mode 100644 index 0000000..89e6a29 --- /dev/null +++ b/test/logger.time-formatting.test.js @@ -0,0 +1,200 @@ +import { assertEquals, assertThrows, assert } from "@std/assert"; +import Logger from '../lib/logger.ts'; +import { + setupMocks, + getCapturedLogs, + clearCapturedLogs, + getFirstLogAsJSON, +} from './helpers/logger-test-helpers.js'; + +// Setup and teardown for all tests +setupMocks(); + +Deno.test("Logger Time Formatting - Default Time Format - should default to short time format", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'json' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + // Short format should be YYYY-MM-DD HH:MM (without seconds) + assert(parsed.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); + assert(!parsed.time.includes('T')); + assert(!parsed.time.includes('Z')); + assert(!parsed.time.includes('.')); +}); + +Deno.test("Logger Time Formatting - Default Time Format - should include time option in logger options", () => { + const shortLogger = new Logger({ time: 'short' }); + const longLogger = new Logger({ time: 'long' }); + const defaultLogger = new Logger(); + + assertEquals(shortLogger.options.time, 'short'); + assertEquals(longLogger.options.time, 'long'); + assertEquals(defaultLogger.options.time, 'short'); // default +}); + +Deno.test("Logger Time Formatting - Short Time Format - should format time as short when time option is 'short'", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'short', format: 'json' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + // Short format: YYYY-MM-DD HH:MM + assert(parsed.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); + assertEquals(parsed.time.length, 16); +}); + +Deno.test("Logger Time Formatting - Short Time Format - should work with simple formatter and short time", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'short', format: 'simple' }); + logger.info('test message'); + + const logOutput = getCapturedLogs()[0]; + + // Should contain short time format in brackets + assert(logOutput.match(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]/)); + assert(!logOutput.includes('T')); + assert(!logOutput.includes('Z')); +}); + +Deno.test("Logger Time Formatting - Short Time Format - should truncate time correctly in short format", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'short', format: 'json' }); + + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + // Short format should not have seconds or milliseconds + assert( + !parsed.time.includes(':') || parsed.time.split(':').length === 2 + ); + assert(!parsed.time.includes('.')); + + // Should be exactly 16 characters: YYYY-MM-DD HH:MM + assertEquals(parsed.time.length, 16); +}); + +Deno.test("Logger Time Formatting - Long Time Format - should format time as long ISO string when time is 'long'", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'long', format: 'json' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + // Long format should be full ISO string + assert(parsed.time.includes('T')); + assert(parsed.time.includes('Z')); + assert(parsed.time.includes('.')); + + // Should be valid ISO string + new Date(parsed.time); // This should not throw + + // Should match ISO format pattern + assert( + parsed.time.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + ); +}); + +Deno.test("Logger Time Formatting - Long Time Format - should work with simple formatter and long time", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'long', format: 'simple' }); + logger.info('test message'); + + const logOutput = getCapturedLogs()[0]; + + // Should contain long time format in brackets + assert( + logOutput.match(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/) + ); + assert(logOutput.includes('T')); + assert(logOutput.includes('Z')); +}); + +Deno.test("Logger Time Formatting - Long Time Format - should preserve time precision in long format", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'long', format: 'json' }); + + const startTime = Date.now(); + logger.info('test message'); + const endTime = Date.now(); + + const parsed = getFirstLogAsJSON(); + const logTime = new Date(parsed.time).getTime(); + + // Log time should be within the test execution window + assert(logTime >= startTime); + assert(logTime <= endTime); + + // Should have millisecond precision + assert(parsed.time.includes('.')); +}); + +Deno.test("Logger Time Formatting - Time Format Consistency - should use consistent time format across multiple log calls", () => { + clearCapturedLogs(); + const logger = new Logger({ time: 'short', format: 'json' }); + + logger.info('first message'); + logger.warn('second message'); + + const logs = getCapturedLogs(); + const parsed1 = JSON.parse(logs[0]); + const parsed2 = JSON.parse(logs[1]); + + // Both should use short format + assert(parsed1.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); + assert(parsed2.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); +}); + +Deno.test("Logger Time Formatting - Time Option Validation - should validate time option in constructor", () => { + // Valid options should not throw + new Logger({ time: 'long' }); + new Logger({ time: 'short' }); + + // Invalid option should throw + assertThrows(() => { + new Logger({ time: 'medium' }); + }, Error, "Invalid time: medium. Valid times are: long, short"); + + assertThrows(() => { + new Logger({ time: 'invalid' }); + }, Error, "Invalid time: invalid. Valid times are: long, short"); +}); + +Deno.test("Logger Time Formatting - Backward Compatibility - should maintain existing behavior for existing code", () => { + clearCapturedLogs(); + + // Code that doesn't specify time option should work as before + const logger = new Logger({ format: 'json', level: 'info' }); + logger.info('test message'); + + const parsed = getFirstLogAsJSON(); + + // Should still have all expected fields + assertEquals(parsed.level, 'info'); + assertEquals(parsed.msg, 'test message'); + assertEquals(typeof parsed.time, 'string'); + assertEquals(typeof parsed.pid, 'number'); + assertEquals(typeof parsed.hostname, 'string'); + + // Time should be in short format (new default) + assert(parsed.time.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)); +}); + +Deno.test("Logger Time Formatting - Backward Compatibility - should not break existing simple formatter tests", () => { + clearCapturedLogs(); + const logger = new Logger({ format: 'simple' }); + logger.warn('warning message'); + + const logOutput = getCapturedLogs()[0]; + + // Should still contain expected elements + assert(logOutput.includes('[WARN ]')); + assert(logOutput.includes('warning message')); + assert(logOutput.includes('.js:')); + + // Should use short time format (new default) + assert(logOutput.match(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]/)); +}); \ No newline at end of file diff --git a/test/logger.util-format.test.js b/test/logger.util-format.test.js new file mode 100644 index 0000000..67cf9fc --- /dev/null +++ b/test/logger.util-format.test.js @@ -0,0 +1,358 @@ +import { assertEquals, assertMatch } from "@std/assert"; +import Logger from '../lib/logger.ts'; + +Deno.test("Logger util.format functionality - Format specifiers - should handle %s string formatting", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('User %s logged in', 'john'); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'User john logged in'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Format specifiers - should handle %d number formatting", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('User has %d points', 100); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'User has 100 points'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Format specifiers - should handle %i integer formatting", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Value: %i', 42.7); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Value: 42'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Format specifiers - should handle %f float formatting", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Price: %f', 19.99); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Price: 19.99'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Format specifiers - should handle %j JSON formatting", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const obj = { name: 'test', value: 42 }; + logger.info('Config: %j', obj); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Config: {"name":"test","value":42}'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Format specifiers - should handle %% literal percentage", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Progress: 50%% complete'); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Progress: 50%% complete'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Multiple format specifiers - should handle multiple format specifiers", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info( + 'User %s has %d points and %f%% completion', + 'alice', + 150, + 75.5 + ); + + const output = JSON.parse(capturedOutput); + assertEquals( + output.msg, + 'User alice has 150 points and 75.5% completion' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Multiple format specifiers - should handle mixed format specifiers with JSON", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const config = { debug: true, port: 3000 }; + logger.info( + 'Server %s running on port %d with config %j', + 'api', + 8080, + config + ); + + const output = JSON.parse(capturedOutput); + assertEquals( + output.msg, + 'Server api running on port 8080 with config {"debug":true,"port":3000}' + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Multiple arguments without format specifiers - should handle multiple arguments without format specifiers", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Message', 'arg1', 'arg2', 123); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Message arg1 arg2 123'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Multiple arguments without format specifiers - should handle mixed objects and primitives", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const obj = { key: 'value' }; + logger.info('Data:', obj, 42, true); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, "Data: { key: 'value' } 42 true"); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Edge cases - should handle more format specifiers than arguments", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Hello %s, you are %d years old', 'John'); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Hello John, you are %d years old'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Edge cases - should handle more arguments than format specifiers", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Hello %s', 'John', 'extra', 'args', 123); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Hello John extra args 123'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Edge cases - should handle null and undefined values", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + logger.info('Values: %s %s %d', null, undefined, null); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Values: null undefined 0'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Edge cases - should handle arrays and objects without %j", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const arr = [1, 2, 3]; + const obj = { a: 1 }; + logger.info('Data %s and %s', arr, obj); + + const output = JSON.parse(capturedOutput); + assertEquals(output.msg, 'Data [ 1, 2, 3 ] and { a: 1 }'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Simple format output - should format messages correctly in simple format", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'simple' }); + logger.info('User %s has %d points', 'bob', 200); + + assertMatch(capturedOutput, /User bob has 200 points/); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Simple format output - should handle JSON formatting in simple format", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'simple' }); + const data = { status: 'active', count: 5 }; + logger.warn('Status: %j', data); + + assertMatch(capturedOutput, /Status: {"status":"active","count":5}/); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Error handling in util.format - should handle objects that throw during toString", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const problematicObj = { + toString() { + throw new Error('toString failed'); + }, + }; + + // The logger might handle the error gracefully, so let's test the actual output + logger.info('Object: %s', problematicObj); + + // Check that something was logged (the logger should handle the error) + const output = JSON.parse(capturedOutput); + assertEquals(typeof output.msg, 'string'); + } finally { + console.log = originalLog; + } +}); + +Deno.test("Logger util.format functionality - Error handling in util.format - should handle circular references with %j", () => { + let capturedOutput = ''; + const originalLog = console.log; + console.log = (message) => { + capturedOutput = message; + }; + + try { + const logger = new Logger({ format: 'json' }); + const circular = { name: 'test' }; + circular.self = circular; + + logger.info('Circular: %j', circular); + + const output = JSON.parse(capturedOutput); + assertMatch(output.msg, /Circular: \[Circular\]/); + } finally { + console.log = originalLog; + } +}); +