import { describe, expect, it, beforeEach, afterEach } from 'vitest' import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { _resetLogger, formatErr, formatLine, getLogPath, initLogger, log, logger } from '../src/main/logger' let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'helder-log-')) _resetLogger() }) afterEach(() => { _resetLogger() rmSync(dir, { recursive: true, force: true }) }) function read(): string { return readFileSync(join(dir, 'helder.log'), 'utf8') } describe('formatLine', () => { it('lays out ts, padded level, pid, scope and message', () => { const line = formatLine('info', 'git', 'loaded', undefined, 42, '2026-07-15T10:00:00.000Z') expect(line).toBe('2026-07-15T10:00:00.000Z INFO 42 git loaded\n') }) it('appends context as JSON', () => { const line = formatLine('warn', 'ipc', 'slow', { ms: 1200 }, 7, '2026-07-15T10:00:00.000Z') expect(line).toContain('{"ms":1200}') expect(line.endsWith('\n')).toBe(true) }) it('indents a multi-line message so a continuation never looks like a new entry', () => { // Electron's own console warnings arrive with embedded newlines. const line = formatLine('warn', 'console', 'line one\nline two', undefined, 1, 'T') expect(line).toBe('T WARN 1 console line one\n line two\n') // Every line after the first is indented → an entry always starts at col 0. for (const l of line.trimEnd().split('\n').slice(1)) expect(l.startsWith(' ')).toBe(true) }) it('indents multi-line context too', () => { const line = formatLine('error', 'x', 'boom', { stack: 'a\nb' }, 1, 'T') // JSON escapes the \n inside the string value, so this stays a single line. expect(line.split('\n').filter(Boolean)).toHaveLength(1) }) }) describe('formatErr', () => { it('keeps message and stack', () => { const e = new Error('nope') const out = formatErr(e) expect(out.message).toBe('nope') expect(out.stack).toContain('nope') }) it('follows cause — the line that usually explains the failure', () => { const root = new Error('EACCES') const e = new Error('save failed', { cause: root }) expect(formatErr(e).cause).toContain('EACCES') }) it('survives a thrown string', () => { expect(formatErr('just a string').message).toBe('just a string') }) it('survives a thrown non-Error object', () => { expect(formatErr({ code: 7 }).message).toBe('{"code":7}') }) it('does not throw on circular values', () => { const a: Record = {} a.self = a expect(() => formatErr(a)).not.toThrow() expect(formatErr(a).message).toContain('Circular') }) }) describe('log', () => { it('creates the file and appends lines', () => { initLogger({ dir }) logger.info('boot', 'hello') logger.warn('boot', 'careful') const out = read() expect(out).toContain('INFO') expect(out).toContain('hello') expect(out).toContain('WARN') expect(out.trim().split('\n')).toHaveLength(2) }) it('creates a missing directory', () => { const nested = join(dir, 'a', 'b') initLogger({ dir: nested }) logger.info('x', 'y') expect(existsSync(join(nested, 'helder.log'))).toBe(true) }) it('records the error with its stack', () => { initLogger({ dir }) logger.error('save', 'write failed', new Error('EACCES: permission denied'), { path: 'a.ts' }) const out = read() expect(out).toContain('EACCES: permission denied') expect(out).toContain('"path":"a.ts"') expect(out).toContain('"stack"') }) it('honours the level floor', () => { initLogger({ dir, level: 'warn' }) logger.debug('x', 'debug line') logger.info('x', 'info line') logger.error('x', 'error line') const out = read() expect(out).not.toContain('debug line') expect(out).not.toContain('info line') expect(out).toContain('error line') }) it('is a no-op before initLogger rather than throwing', () => { expect(() => logger.info('x', 'y')).not.toThrow() expect(getLogPath()).toBeNull() }) it('exposes the active path', () => { initLogger({ dir }) expect(getLogPath()).toBe(join(dir, 'helder.log')) }) it('never throws even when the log path is unwritable', () => { // Point at a path whose parent is a FILE: every append must fail. const wall = join(dir, 'wall') writeFileSync(wall, 'x') initLogger({ dir: join(wall, 'sub') }) expect(() => logger.error('x', 'still fine')).not.toThrow() }) }) describe('rotation', () => { it('rolls the file once it passes the size cap and keeps writing', () => { initLogger({ dir }) const big = 'x'.repeat(4000) // 2MB cap / ~4KB per line → ~525 lines to trip it. 700 is comfortably past. for (let i = 0; i < 700; i++) log('info', 'bulk', big) expect(existsSync(join(dir, 'helder.1.log'))).toBe(true) // The live file is the post-rotation one and is still being appended to. logger.info('after', 'still logging') expect(read()).toContain('still logging') // And it's small again — proof the rotation actually moved the bytes. expect(read().length).toBeLessThan(4000 * 700) }) })