/ tests / utils / logger.test.js
logger.test.js
  1  /**
  2   * Unit tests for Logger utility
  3   */
  4  
  5  import { describe, test, mock, beforeEach, afterEach } from 'node:test';
  6  import assert from 'node:assert';
  7  import fs from 'fs';
  8  import os from 'os';
  9  import path from 'path';
 10  import Logger from '../../src/utils/logger.js';
 11  
 12  describe('Logger Module', () => {
 13    let consoleLogSpy;
 14    let consoleWarnSpy;
 15    let consoleErrorSpy;
 16    let stdoutWriteSpy;
 17    let originalDebug;
 18  
 19    beforeEach(() => {
 20      // Mock console methods to capture output
 21      consoleLogSpy = mock.method(console, 'log', () => {});
 22      consoleWarnSpy = mock.method(console, 'warn', () => {});
 23      consoleErrorSpy = mock.method(console, 'error', () => {});
 24      stdoutWriteSpy = mock.method(process.stdout, 'write', () => {});
 25      originalDebug = process.env.DEBUG;
 26    });
 27  
 28    afterEach(() => {
 29      // Restore console methods
 30      consoleLogSpy.mock.restore();
 31      consoleWarnSpy.mock.restore();
 32      consoleErrorSpy.mock.restore();
 33      stdoutWriteSpy.mock.restore();
 34      process.env.DEBUG = originalDebug;
 35    });
 36  
 37    describe('constructor', () => {
 38      test('should create logger with default context', () => {
 39        const logger = new Logger();
 40        assert.strictEqual(logger.context, '333');
 41      });
 42  
 43      test('should create logger with custom context', () => {
 44        const logger = new Logger('test-context');
 45        assert.strictEqual(logger.context, 'test-context');
 46      });
 47    });
 48  
 49    describe('info', () => {
 50      test('should log info message without data', () => {
 51        const logger = new Logger('test');
 52        logger.info('Test info message');
 53  
 54        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
 55        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
 56        assert.ok(logOutput.includes('[test]'));
 57        assert.ok(logOutput.includes('[INFO]'));
 58        assert.ok(logOutput.includes('Test info message'));
 59      });
 60  
 61      test('should log info message with data', () => {
 62        const logger = new Logger('test');
 63        const testData = { key: 'value', number: 42 };
 64        logger.info('Test with data', testData);
 65  
 66        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
 67        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
 68        assert.ok(logOutput.includes('Test with data'));
 69        assert.ok(logOutput.includes('"key": "value"'));
 70        assert.ok(logOutput.includes('"number": 42'));
 71      });
 72    });
 73  
 74    describe('success', () => {
 75      test('should log success message without data', () => {
 76        const logger = new Logger('test');
 77        logger.success('Operation succeeded');
 78  
 79        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
 80        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
 81        assert.ok(logOutput.includes('[SUCCESS]'));
 82        assert.ok(logOutput.includes('Operation succeeded'));
 83      });
 84  
 85      test('should log success message with data', () => {
 86        const logger = new Logger('test');
 87        logger.success('Success with stats', { processed: 10, failed: 0 });
 88  
 89        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
 90        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
 91        assert.ok(logOutput.includes('Success with stats'));
 92        assert.ok(logOutput.includes('"processed": 10'));
 93      });
 94    });
 95  
 96    describe('warn', () => {
 97      test('should log warning message without data', () => {
 98        const logger = new Logger('test');
 99        logger.warn('Warning message');
100  
101        assert.strictEqual(consoleWarnSpy.mock.calls.length, 1);
102        const logOutput = consoleWarnSpy.mock.calls[0].arguments[0];
103        assert.ok(logOutput.includes('[WARN]'));
104        assert.ok(logOutput.includes('Warning message'));
105      });
106  
107      test('should log warning message with data', () => {
108        const logger = new Logger('test');
109        logger.warn('Warning with details', { retry: true });
110  
111        assert.strictEqual(consoleWarnSpy.mock.calls.length, 1);
112        const logOutput = consoleWarnSpy.mock.calls[0].arguments[0];
113        assert.ok(logOutput.includes('Warning with details'));
114        assert.ok(logOutput.includes('"retry": true'));
115      });
116    });
117  
118    describe('error', () => {
119      test('should log error message without error object', () => {
120        const logger = new Logger('test');
121        logger.error('Error occurred');
122  
123        assert.strictEqual(consoleErrorSpy.mock.calls.length, 1);
124        const logOutput = consoleErrorSpy.mock.calls[0].arguments[0];
125        assert.ok(logOutput.includes('[ERROR]'));
126        assert.ok(logOutput.includes('Error occurred'));
127      });
128  
129      test('should log error message with error object', () => {
130        const logger = new Logger('test');
131        const testError = new Error('Test error');
132        logger.error('Operation failed', testError);
133  
134        assert.strictEqual(consoleErrorSpy.mock.calls.length, 1);
135        const logOutput = consoleErrorSpy.mock.calls[0].arguments[0];
136        assert.ok(logOutput.includes('Operation failed'));
137        assert.ok(logOutput.includes('Test error'));
138        assert.ok(logOutput.includes('stack'));
139      });
140    });
141  
142    describe('debug', () => {
143      test('should log debug message when DEBUG=true', () => {
144        process.env.DEBUG = 'true';
145        const logger = new Logger('test');
146        logger.debug('Debug message');
147  
148        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
149        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
150        assert.ok(logOutput.includes('[DEBUG]'));
151        assert.ok(logOutput.includes('Debug message'));
152      });
153  
154      test('should not log debug message when DEBUG is not set', () => {
155        delete process.env.DEBUG;
156        const logger = new Logger('test');
157        logger.debug('Debug message');
158  
159        assert.strictEqual(consoleLogSpy.mock.calls.length, 0);
160      });
161  
162      test('should not log debug message when DEBUG=false', () => {
163        process.env.DEBUG = 'false';
164        const logger = new Logger('test');
165        logger.debug('Debug message');
166  
167        assert.strictEqual(consoleLogSpy.mock.calls.length, 0);
168      });
169  
170      test('should log debug message with data when DEBUG=true', () => {
171        process.env.DEBUG = 'true';
172        const logger = new Logger('test');
173        logger.debug('Debug with data', { detail: 'value' });
174  
175        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
176        const logOutput = consoleLogSpy.mock.calls[0].arguments[0];
177        assert.ok(logOutput.includes('Debug with data'));
178        assert.ok(logOutput.includes('"detail": "value"'));
179      });
180    });
181  
182    describe('progress', () => {
183      test('should display progress bar at 0%', () => {
184        const logger = new Logger('test');
185        logger.progress(0, 100);
186  
187        assert.strictEqual(stdoutWriteSpy.mock.calls.length, 1);
188        const output = stdoutWriteSpy.mock.calls[0].arguments[0];
189        assert.ok(output.includes('0%'));
190        assert.ok(output.includes('░'.repeat(50)));
191      });
192  
193      test('should display progress bar at 50%', () => {
194        const logger = new Logger('test');
195        logger.progress(50, 100);
196  
197        assert.strictEqual(stdoutWriteSpy.mock.calls.length, 1);
198        const output = stdoutWriteSpy.mock.calls[0].arguments[0];
199        assert.ok(output.includes('50%'));
200        assert.ok(output.includes('█'.repeat(25)));
201        assert.ok(output.includes('░'.repeat(25)));
202      });
203  
204      test('should display progress bar at 100% and add newline', () => {
205        const logger = new Logger('test');
206        logger.progress(100, 100);
207  
208        // Should call stdout.write once for progress bar
209        assert.strictEqual(stdoutWriteSpy.mock.calls.length, 1);
210        const output = stdoutWriteSpy.mock.calls[0].arguments[0];
211        assert.ok(output.includes('100%'));
212        assert.ok(output.includes('█'.repeat(50)));
213  
214        // Should call console.log once for newline
215        assert.strictEqual(consoleLogSpy.mock.calls.length, 1);
216      });
217  
218      test('should display progress bar with custom message', () => {
219        const logger = new Logger('test');
220        logger.progress(25, 100, 'Processing files...');
221  
222        assert.strictEqual(stdoutWriteSpy.mock.calls.length, 1);
223        const output = stdoutWriteSpy.mock.calls[0].arguments[0];
224        assert.ok(output.includes('25%'));
225        assert.ok(output.includes('Processing files...'));
226      });
227    });
228  
229    describe('_format', () => {
230      test('should format message without data', () => {
231        const logger = new Logger('test');
232        const formatted = logger._format('INFO', 'Test message');
233  
234        assert.ok(formatted.includes('[test]'));
235        assert.ok(formatted.includes('[INFO]'));
236        assert.ok(formatted.includes('Test message'));
237        // Should include ISO timestamp
238        assert.ok(formatted.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/));
239      });
240  
241      test('should format message with data', () => {
242        const logger = new Logger('test');
243        const testData = { key: 'value' };
244        const formatted = logger._format('INFO', 'Test message', testData);
245  
246        assert.ok(formatted.includes('Test message'));
247        assert.ok(formatted.includes('"key": "value"'));
248      });
249    });
250  
251    describe('_getLogDomain', () => {
252      test('returns pipeline for pipeline context', () => {
253        const logger = new Logger('pipeline');
254        assert.strictEqual(logger._getLogDomain('pipeline'), 'pipeline');
255      });
256  
257      test('returns pipeline for pipelineservice context', () => {
258        const logger = new Logger('test');
259        assert.strictEqual(logger._getLogDomain('pipelineservice'), 'pipeline');
260      });
261  
262      test('returns keywords for keywords context', () => {
263        const logger = new Logger('test');
264        assert.strictEqual(logger._getLogDomain('keywords'), 'keywords');
265      });
266  
267      test('returns keywords for keywordmanager context', () => {
268        const logger = new Logger('test');
269        assert.strictEqual(logger._getLogDomain('keywordmanager'), 'keywords');
270      });
271  
272      test('returns serps for serps context', () => {
273        const logger = new Logger('test');
274        assert.strictEqual(logger._getLogDomain('serps'), 'serps');
275      });
276  
277      test('returns serps for scraper context', () => {
278        const logger = new Logger('test');
279        assert.strictEqual(logger._getLogDomain('scraper'), 'serps');
280      });
281  
282      test('returns assets for assets context', () => {
283        const logger = new Logger('test');
284        assert.strictEqual(logger._getLogDomain('assets'), 'assets');
285      });
286  
287      test('returns assets for capture context', () => {
288        const logger = new Logger('test');
289        assert.strictEqual(logger._getLogDomain('capture'), 'assets');
290      });
291  
292      test('returns scoring for scoring context', () => {
293        const logger = new Logger('test');
294        assert.strictEqual(logger._getLogDomain('scoring'), 'scoring');
295      });
296  
297      test('returns rescoring for rescoring context', () => {
298        const logger = new Logger('test');
299        assert.strictEqual(logger._getLogDomain('rescoring'), 'rescoring');
300      });
301  
302      test('returns enrich for enrich context', () => {
303        const logger = new Logger('test');
304        assert.strictEqual(logger._getLogDomain('enrich'), 'enrich');
305      });
306  
307      test('returns proposals for proposals context', () => {
308        const logger = new Logger('test');
309        assert.strictEqual(logger._getLogDomain('proposals'), 'proposals');
310      });
311  
312      test('returns outreach for outreach context', () => {
313        const logger = new Logger('test');
314        assert.strictEqual(logger._getLogDomain('outreach'), 'outreach');
315      });
316  
317      test('returns outreach for smsoutreach context', () => {
318        const logger = new Logger('test');
319        assert.strictEqual(logger._getLogDomain('smsoutreach'), 'outreach');
320      });
321  
322      test('returns outreach for emailoutreach context', () => {
323        const logger = new Logger('test');
324        assert.strictEqual(logger._getLogDomain('emailoutreach'), 'outreach');
325      });
326  
327      test('returns outreach for formoutreach context', () => {
328        const logger = new Logger('test');
329        assert.strictEqual(logger._getLogDomain('formoutreach'), 'outreach');
330      });
331  
332      test('returns inbound for replies context', () => {
333        const logger = new Logger('test');
334        assert.strictEqual(logger._getLogDomain('replies'), 'inbound');
335      });
336  
337      test('returns inbound for inboundsms context', () => {
338        const logger = new Logger('test');
339        assert.strictEqual(logger._getLogDomain('inboundsms'), 'inbound');
340      });
341  
342      test('returns dashboard for dashboard context', () => {
343        const logger = new Logger('test');
344        assert.strictEqual(logger._getLogDomain('dashboard'), 'dashboard');
345      });
346  
347      test('returns dashboard for overview context', () => {
348        const logger = new Logger('test');
349        assert.strictEqual(logger._getLogDomain('overview'), 'dashboard');
350      });
351  
352      test('returns agents for agents context', () => {
353        const logger = new Logger('test');
354        assert.strictEqual(logger._getLogDomain('agents'), 'agents');
355      });
356  
357      test('returns agents for developer context', () => {
358        const logger = new Logger('test');
359        assert.strictEqual(logger._getLogDomain('developer'), 'agents');
360      });
361  
362      test('returns agents for qa context', () => {
363        const logger = new Logger('test');
364        assert.strictEqual(logger._getLogDomain('qa'), 'agents');
365      });
366  
367      test('returns cron for cron context', () => {
368        const logger = new Logger('test');
369        assert.strictEqual(logger._getLogDomain('cron'), 'cron');
370      });
371  
372      test('returns cron for dailylogrotation context', () => {
373        const logger = new Logger('test');
374        assert.strictEqual(logger._getLogDomain('dailylogrotation'), 'cron');
375      });
376  
377      test('returns utils for dedupe context', () => {
378        const logger = new Logger('test');
379        assert.strictEqual(logger._getLogDomain('dedupe'), 'utils');
380      });
381  
382      test('returns utils for errorhandler context', () => {
383        const logger = new Logger('test');
384        assert.strictEqual(logger._getLogDomain('errorhandler'), 'utils');
385      });
386  
387      test('returns tests for test context', () => {
388        const logger = new Logger('test');
389        assert.strictEqual(logger._getLogDomain('test'), 'tests');
390      });
391  
392      test('returns app for unknown context', () => {
393        const logger = new Logger('test');
394        assert.strictEqual(logger._getLogDomain('unknownmodule'), 'app');
395      });
396  
397      test('normalizes context by lowercasing before lookup', () => {
398        const logger = new Logger('test');
399        assert.strictEqual(logger._getLogDomain('PIPELINE'), 'pipeline');
400      });
401  
402      test('normalizes context by stripping special chars', () => {
403        const logger = new Logger('test');
404        // 'cronjobs' is a dashboard page key in the domain map
405        assert.strictEqual(logger._getLogDomain('cronjobs'), 'dashboard');
406      });
407  
408      test('strips hyphens from context before lookup', () => {
409        const logger = new Logger('test');
410        // 'cron-job' normalizes to 'cronjob' which is not in map, falls back to app
411        assert.strictEqual(logger._getLogDomain('cron-job'), 'app');
412      });
413    });
414  
415    describe('file writing (logToFile: true)', () => {
416      let tmpDir;
417      let savedNodeEnv;
418  
419      // Helper: create a logger with file writing enabled by temporarily clearing NODE_ENV.
420      // Logger constructor disables file logging when NODE_ENV === 'test', so we clear it
421      // before constructing and restore it immediately after.
422      function makeFileLogger(context, extraOpts = {}) {
423        const env = process.env.NODE_ENV;
424        delete process.env.NODE_ENV;
425        const logger = new Logger(context, { logDir: tmpDir, ...extraOpts });
426        if (env !== undefined) {
427          process.env.NODE_ENV = env;
428        }
429        return logger;
430      }
431  
432      // Helper: close logger and flush the write stream before the file is read
433      async function closeAndFlush(logger) {
434        await new Promise(resolve => {
435          if (logger.logStream && !logger.logStream.destroyed) {
436            logger.logStream.end(resolve);
437          } else {
438            resolve();
439          }
440        });
441      }
442  
443      beforeEach(() => {
444        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-'));
445        savedNodeEnv = process.env.NODE_ENV;
446      });
447  
448      afterEach(() => {
449        if (savedNodeEnv !== undefined) {
450          process.env.NODE_ENV = savedNodeEnv;
451        }
452        // Clean up temp directory
453        try {
454          fs.rmSync(tmpDir, { recursive: true, force: true });
455        } catch (_e) {
456          // ignore cleanup errors
457        }
458      });
459  
460      test('creates log directory if it does not exist', () => {
461        const newDir = path.join(tmpDir, 'nested', 'logs');
462        const logger = makeFileLogger('test', { logDir: newDir });
463        // mkdirSync is synchronous so dir is created immediately
464        const exists = fs.existsSync(newDir);
465        logger.close();
466        assert.ok(exists, 'log directory should be created');
467      });
468  
469      test('logFile path is set on initialization', () => {
470        const logger = makeFileLogger('test');
471        // logFile path is set synchronously even before first write
472        assert.ok(logger.logFile, 'logFile should be set');
473        assert.ok(logger.logFile.includes(tmpDir), 'logFile should be in tmpDir');
474        logger.close();
475      });
476  
477      test('log file path includes domain and date', () => {
478        const logger = makeFileLogger('pipeline');
479        assert.ok(logger.logFile.includes('pipeline'), 'log file path should include domain');
480        assert.ok(logger.logFile.match(/\d{4}-\d{2}-\d{2}/), 'log file path should include date');
481        logger.close();
482      });
483  
484      test('writes info messages to file', async () => {
485        const logger = makeFileLogger('test');
486        logger.info('File write test message');
487        await closeAndFlush(logger);
488        const contents = fs.readFileSync(logger.logFile, 'utf8');
489        assert.ok(contents.includes('File write test message'), 'file should contain log message');
490        assert.ok(contents.includes('[INFO]'), 'file should contain log level');
491        // ANSI codes should be stripped from file output
492        assert.ok(!contents.includes('\x1b['), 'file should not contain ANSI codes');
493      });
494  
495      test('writes success messages to file', async () => {
496        const logger = makeFileLogger('test');
497        logger.success('Success write test');
498        await closeAndFlush(logger);
499        const contents = fs.readFileSync(logger.logFile, 'utf8');
500        assert.ok(contents.includes('Success write test'), 'file should contain success message');
501        assert.ok(contents.includes('[SUCCESS]'), 'file should contain SUCCESS level');
502      });
503  
504      test('writes warn messages to file', async () => {
505        const logger = makeFileLogger('test');
506        logger.warn('Warning write test');
507        await closeAndFlush(logger);
508        const contents = fs.readFileSync(logger.logFile, 'utf8');
509        assert.ok(contents.includes('Warning write test'), 'file should contain warn message');
510        assert.ok(contents.includes('[WARN]'), 'file should contain WARN level');
511      });
512  
513      test('writes error messages to file', async () => {
514        const logger = makeFileLogger('test');
515        const err = new Error('test error detail');
516        logger.error('Error write test', err);
517        await closeAndFlush(logger);
518        const contents = fs.readFileSync(logger.logFile, 'utf8');
519        assert.ok(contents.includes('Error write test'), 'file should contain error message');
520        assert.ok(contents.includes('[ERROR]'), 'file should contain ERROR level');
521        assert.ok(contents.includes('test error detail'), 'file should contain error detail');
522      });
523  
524      test('writes debug messages to file regardless of DEBUG env var', async () => {
525        delete process.env.DEBUG;
526        const logger = makeFileLogger('test');
527        logger.debug('Debug write test');
528        await closeAndFlush(logger);
529        const contents = fs.readFileSync(logger.logFile, 'utf8');
530        assert.ok(contents.includes('Debug write test'), 'file should contain debug message');
531        assert.ok(contents.includes('[DEBUG]'), 'file should contain DEBUG level');
532      });
533  
534      test('appends multiple messages to same file', async () => {
535        const logger = makeFileLogger('test');
536        logger.info('First message');
537        logger.info('Second message');
538        logger.warn('Third message');
539        await closeAndFlush(logger);
540        const contents = fs.readFileSync(logger.logFile, 'utf8');
541        assert.ok(contents.includes('First message'));
542        assert.ok(contents.includes('Second message'));
543        assert.ok(contents.includes('Third message'));
544      });
545  
546      test('uses app domain for unknown context when writing to file', () => {
547        const logger = makeFileLogger('unknownmodule');
548        assert.ok(logger.logFile.includes('app'), 'unknown context should use app domain');
549        logger.close();
550      });
551  
552      test('logStream is open before close() is called', () => {
553        const logger = makeFileLogger('test');
554        assert.ok(logger.logStream, 'logStream should exist');
555        assert.ok(!logger.logStream.destroyed, 'stream should not be destroyed before close');
556        logger.close();
557      });
558  
559      test('close() ends the log stream', async () => {
560        const logger = makeFileLogger('test');
561        await closeAndFlush(logger);
562        assert.ok(
563          logger.logStream.destroyed || logger.logStream.writableEnded,
564          'stream should be ended after close'
565        );
566      });
567  
568      test('_writeToFile does nothing if stream is destroyed', async () => {
569        const logger = makeFileLogger('test');
570        const logFilePath = logger.logFile;
571        // Write something first to ensure the file exists
572        logger.info('before close');
573        await closeAndFlush(logger);
574        const beforeSize = fs.existsSync(logFilePath) ? fs.statSync(logFilePath).size : 0;
575        // After close, writing should be a no-op (stream is destroyed/ended)
576        logger._writeToFile('should not appear in file');
577        await new Promise(resolve => setTimeout(resolve, 50));
578        const afterSize = fs.existsSync(logFilePath) ? fs.statSync(logFilePath).size : 0;
579        assert.strictEqual(
580          beforeSize,
581          afterSize,
582          'file size should not change after stream destroyed'
583        );
584      });
585    });
586  
587    describe('constructor logToFile option', () => {
588      test('logToFile is disabled in test environment (NODE_ENV=test)', () => {
589        // NODE_ENV is set to 'test' by the test runner prefix, so logToFile defaults to false
590        process.env.NODE_ENV = 'test';
591        const logger = new Logger('test');
592        assert.strictEqual(logger.logToFile, false, 'logToFile should be false in NODE_ENV=test');
593        assert.strictEqual(logger.logStream, null, 'logStream should be null when logToFile=false');
594      });
595  
596      test('logToFile can be explicitly disabled', () => {
597        const logger = new Logger('test', { logToFile: false });
598        assert.strictEqual(logger.logToFile, false);
599        assert.strictEqual(logger.logStream, null);
600      });
601  
602      test('close() is safe to call when logStream is null', () => {
603        process.env.NODE_ENV = 'test';
604        const logger = new Logger('test');
605        // Should not throw even when logStream is null
606        assert.doesNotThrow(() => logger.close());
607      });
608    });
609  
610    describe('additional branch coverage', () => {
611      let tmpDir;
612      let savedNodeEnv;
613  
614      function makeFileLogger(context, extraOpts = {}) {
615        const env = process.env.NODE_ENV;
616        delete process.env.NODE_ENV;
617        const logger = new Logger(context, { logDir: tmpDir, ...extraOpts });
618        if (env !== undefined) {
619          process.env.NODE_ENV = env;
620        }
621        return logger;
622      }
623  
624      beforeEach(() => {
625        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-branch-test-'));
626        savedNodeEnv = process.env.NODE_ENV;
627      });
628  
629      afterEach(() => {
630        if (savedNodeEnv !== undefined) {
631          process.env.NODE_ENV = savedNodeEnv;
632        }
633        try {
634          fs.rmSync(tmpDir, { recursive: true, force: true });
635        } catch (_e) {
636          // ignore
637        }
638      });
639  
640      test('does not recreate log directory when it already exists', () => {
641        // logDir already exists from beforeEach — _initializeLogFile should skip mkdirSync
642        const logger = makeFileLogger('test');
643        // If it tries to create it again it would still succeed, but we verify no error thrown
644        assert.ok(logger.logFile, 'logFile should be set even when dir already exists');
645        logger.close();
646      });
647  
648      test('logStream error event handler logs to console.error without throwing', () => {
649        const logger = makeFileLogger('test');
650        // Replace console.error to capture the error log (the outer beforeEach spy is already
651        // on console.error, but we want to check the raw string, so use the spy's captured calls)
652        // The outer consoleErrorSpy intercepts console.error calls.
653        // We emit a stream error and check that console.error was called with the expected message.
654        logger.logStream.emit('error', new Error('stream write failure'));
655        // console.error is currently mocked by consoleErrorSpy from the outer describe
656        const { calls } = consoleErrorSpy.mock;
657        const found = calls.some(c => String(c.arguments[0]).includes('Failed to write to log file'));
658        assert.ok(found, 'error handler should call console.error with failure message');
659        logger.close();
660      });
661  
662      test('uses LOGS_DIR env variable as default logDir', () => {
663        const customDir = path.join(tmpDir, 'from-env');
664        fs.mkdirSync(customDir, { recursive: true });
665        process.env.LOGS_DIR = customDir;
666        const env = process.env.NODE_ENV;
667        delete process.env.NODE_ENV;
668        const logger = new Logger('test');
669        if (env !== undefined) process.env.NODE_ENV = env;
670        delete process.env.LOGS_DIR;
671        assert.ok(logger.logFile.startsWith(customDir), 'should use LOGS_DIR env var as logDir');
672        logger.close();
673      });
674    });
675  });