Files
helder/test/logger.test.ts

152 lines
5.2 KiB
TypeScript

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<string, unknown> = {}
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)
})
})