This commit is contained in:
IanKulin
2025-09-25 20:50:13 +08:00
commit 9546dd3ab3
17 changed files with 3248 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

225
README.md Normal file
View File

@@ -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.

94
demo.js Normal file
View File

@@ -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 });

12
deno.json Normal file
View File

@@ -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"
}
}

52
deno.lock generated Normal file
View File

@@ -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"
}
}

483
lib/logger.ts Normal file
View File

@@ -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<LogLevels>;
format?: 'json' | 'simple';
time?: 'long' | 'short';
callerLevel?: LogLevel;
colours?: Partial<Colours>;
}
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;

View File

@@ -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]);
}

View File

@@ -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;
}
});

View File

@@ -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 },
});
});

View File

@@ -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
});
});

View File

@@ -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
});

View File

@@ -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);
});

View File

@@ -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
});

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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}\]/));
});

View File

@@ -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;
}
});