test(01.1-04): add unit tests for LogManager and ProcessLogWriter

- LogManager tests: ensureLogDir, ensureProcessDir, getProcessDir, getLogPath, listLogs, cleanOldLogs, getBaseDir
- ProcessLogWriter tests: open, writeStdout, writeStderr, close, append mode
- Uses temp directories for file system tests with proper cleanup
- 35 new tests covering all logging module functionality
This commit is contained in:
Lukas May
2026-01-30 14:01:59 +01:00
parent ea79b3bf08
commit 17f4e61713
2 changed files with 480 additions and 0 deletions

215
src/logging/manager.test.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* LogManager Tests
*
* Tests for the log directory and file path management.
* Uses temporary directories to avoid polluting the real log directory.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, writeFile, utimes } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LogManager } from './manager.js';
describe('LogManager', () => {
let testDir: string;
let manager: LogManager;
beforeEach(async () => {
// Create a unique temp directory for each test
testDir = join(tmpdir(), `cw-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
manager = new LogManager({ baseDir: testDir });
});
afterEach(async () => {
// Clean up temp directory after each test
try {
await rm(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getBaseDir', () => {
it('should return the configured base directory', () => {
expect(manager.getBaseDir()).toBe(testDir);
});
it('should use default directory when not configured', () => {
const defaultManager = new LogManager();
expect(defaultManager.getBaseDir()).toContain('.cw');
expect(defaultManager.getBaseDir()).toContain('logs');
});
});
describe('ensureLogDir', () => {
it('should create the base log directory', async () => {
expect(existsSync(testDir)).toBe(false);
await manager.ensureLogDir();
expect(existsSync(testDir)).toBe(true);
});
it('should not error if directory already exists', async () => {
await mkdir(testDir, { recursive: true });
expect(existsSync(testDir)).toBe(true);
// Should not throw
await manager.ensureLogDir();
expect(existsSync(testDir)).toBe(true);
});
});
describe('ensureProcessDir', () => {
it('should create the process-specific log directory', async () => {
const processId = 'test-process-123';
const expectedDir = join(testDir, processId);
expect(existsSync(expectedDir)).toBe(false);
await manager.ensureProcessDir(processId);
expect(existsSync(expectedDir)).toBe(true);
});
it('should create nested directories if base does not exist', async () => {
const processId = 'nested-process';
const expectedDir = join(testDir, processId);
expect(existsSync(testDir)).toBe(false);
await manager.ensureProcessDir(processId);
expect(existsSync(testDir)).toBe(true);
expect(existsSync(expectedDir)).toBe(true);
});
});
describe('getProcessDir', () => {
it('should return the correct path for a process', () => {
const processId = 'my-process';
const expected = join(testDir, processId);
expect(manager.getProcessDir(processId)).toBe(expected);
});
});
describe('getLogPath', () => {
it('should return correct path for stdout log', () => {
const processId = 'proc-1';
const expected = join(testDir, processId, 'stdout.log');
expect(manager.getLogPath(processId, 'stdout')).toBe(expected);
});
it('should return correct path for stderr log', () => {
const processId = 'proc-2';
const expected = join(testDir, processId, 'stderr.log');
expect(manager.getLogPath(processId, 'stderr')).toBe(expected);
});
});
describe('listLogs', () => {
it('should return empty array if base directory does not exist', async () => {
expect(existsSync(testDir)).toBe(false);
const logs = await manager.listLogs();
expect(logs).toEqual([]);
});
it('should return empty array if no log directories exist', async () => {
await mkdir(testDir, { recursive: true });
const logs = await manager.listLogs();
expect(logs).toEqual([]);
});
it('should return process IDs for existing log directories', async () => {
// Create some process directories
await mkdir(join(testDir, 'process-a'), { recursive: true });
await mkdir(join(testDir, 'process-b'), { recursive: true });
await mkdir(join(testDir, 'process-c'), { recursive: true });
const logs = await manager.listLogs();
expect(logs).toHaveLength(3);
expect(logs).toContain('process-a');
expect(logs).toContain('process-b');
expect(logs).toContain('process-c');
});
it('should only return directories, not files', async () => {
await mkdir(testDir, { recursive: true });
await mkdir(join(testDir, 'valid-process'), { recursive: true });
await writeFile(join(testDir, 'some-file.txt'), 'not a directory');
const logs = await manager.listLogs();
expect(logs).toEqual(['valid-process']);
});
});
describe('cleanOldLogs', () => {
it('should return 0 when retainDays is not configured', async () => {
const managerNoRetain = new LogManager({ baseDir: testDir });
const removed = await managerNoRetain.cleanOldLogs();
expect(removed).toBe(0);
});
it('should return 0 when no directories exist', async () => {
const managerWithRetain = new LogManager({ baseDir: testDir, retainDays: 7 });
const removed = await managerWithRetain.cleanOldLogs();
expect(removed).toBe(0);
});
it('should remove directories older than retainDays', async () => {
const managerWithRetain = new LogManager({ baseDir: testDir, retainDays: 7 });
// Create an "old" directory
const oldDir = join(testDir, 'old-process');
await mkdir(oldDir, { recursive: true });
// Set mtime to 10 days ago
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
await utimes(oldDir, tenDaysAgo, tenDaysAgo);
// Create a "new" directory
const newDir = join(testDir, 'new-process');
await mkdir(newDir, { recursive: true });
const removed = await managerWithRetain.cleanOldLogs();
expect(removed).toBe(1);
expect(existsSync(oldDir)).toBe(false);
expect(existsSync(newDir)).toBe(true);
});
it('should use provided retainDays over config value', async () => {
const managerWithRetain = new LogManager({ baseDir: testDir, retainDays: 30 });
// Create directory that is 10 days old
const oldDir = join(testDir, 'process');
await mkdir(oldDir, { recursive: true });
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
await utimes(oldDir, tenDaysAgo, tenDaysAgo);
// With config (30 days), should NOT remove
expect(await managerWithRetain.cleanOldLogs()).toBe(0);
expect(existsSync(oldDir)).toBe(true);
// With explicit 5 days, SHOULD remove
expect(await managerWithRetain.cleanOldLogs(5)).toBe(1);
expect(existsSync(oldDir)).toBe(false);
});
});
});

265
src/logging/writer.test.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* ProcessLogWriter Tests
*
* Tests for the per-process log writing functionality.
* Uses temporary directories to avoid polluting the real log directory.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LogManager } from './manager.js';
import { ProcessLogWriter } from './writer.js';
describe('ProcessLogWriter', () => {
let testDir: string;
let manager: LogManager;
let writer: ProcessLogWriter;
const processId = 'test-process';
beforeEach(async () => {
// Create a unique temp directory for each test
testDir = join(tmpdir(), `cw-writer-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
manager = new LogManager({ baseDir: testDir });
writer = new ProcessLogWriter(processId, manager);
});
afterEach(async () => {
// Ensure writer is closed before cleanup
try {
await writer.close();
} catch {
// Ignore if already closed
}
// Clean up temp directory
try {
await rm(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getProcessId', () => {
it('should return the process ID', () => {
expect(writer.getProcessId()).toBe(processId);
});
});
describe('open', () => {
it('should create the process log directory', async () => {
const processDir = manager.getProcessDir(processId);
expect(existsSync(processDir)).toBe(false);
await writer.open();
expect(existsSync(processDir)).toBe(true);
});
it('should create stdout.log and stderr.log files', async () => {
await writer.open();
const stdoutPath = manager.getLogPath(processId, 'stdout');
const stderrPath = manager.getLogPath(processId, 'stderr');
expect(existsSync(stdoutPath)).toBe(true);
expect(existsSync(stderrPath)).toBe(true);
});
it('should make streams available after open', async () => {
expect(writer.getStdoutStream()).toBeNull();
expect(writer.getStderrStream()).toBeNull();
await writer.open();
expect(writer.getStdoutStream()).not.toBeNull();
expect(writer.getStderrStream()).not.toBeNull();
});
});
describe('writeStdout', () => {
it('should throw if writer is not open', async () => {
await expect(writer.writeStdout('test')).rejects.toThrow(
'Log writer not open'
);
});
it('should write string data to stdout.log', async () => {
await writer.open();
await writer.writeStdout('Hello stdout\n');
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
expect(content).toContain('Hello stdout');
});
it('should write Buffer data to stdout.log', async () => {
await writer.open();
await writer.writeStdout(Buffer.from('Buffer content\n'));
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
expect(content).toContain('Buffer content');
});
it('should prefix lines with timestamps', async () => {
await writer.open();
await writer.writeStdout('Line one\n');
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
// Timestamp format: [YYYY-MM-DD HH:mm:ss.SSS]
expect(content).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/);
});
it('should handle multiple lines with individual timestamps', async () => {
await writer.open();
await writer.writeStdout('Line one\nLine two\nLine three\n');
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
const lines = content.split('\n').filter((l) => l.length > 0);
expect(lines.length).toBe(3);
// Each line should have a timestamp
for (const line of lines) {
expect(line).toMatch(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/);
}
});
});
describe('writeStderr', () => {
it('should throw if writer is not open', async () => {
await expect(writer.writeStderr('test')).rejects.toThrow(
'Log writer not open'
);
});
it('should write string data to stderr.log', async () => {
await writer.open();
await writer.writeStderr('Error message\n');
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stderr'),
'utf-8'
);
expect(content).toContain('Error message');
});
it('should write Buffer data to stderr.log', async () => {
await writer.open();
await writer.writeStderr(Buffer.from('Error buffer\n'));
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stderr'),
'utf-8'
);
expect(content).toContain('Error buffer');
});
it('should prefix lines with timestamps', async () => {
await writer.open();
await writer.writeStderr('Error line\n');
await writer.close();
const content = await readFile(
manager.getLogPath(processId, 'stderr'),
'utf-8'
);
expect(content).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/);
});
});
describe('close', () => {
it('should flush and close file handles', async () => {
await writer.open();
await writer.writeStdout('Before close\n');
await writer.close();
// Streams should be null after close
expect(writer.getStdoutStream()).toBeNull();
expect(writer.getStderrStream()).toBeNull();
// Data should be flushed to disk
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
expect(content).toContain('Before close');
});
it('should not error if called multiple times', async () => {
await writer.open();
await writer.close();
// Should not throw
await writer.close();
});
});
describe('write after close', () => {
it('should throw when writing to stdout after close', async () => {
await writer.open();
await writer.close();
await expect(writer.writeStdout('test')).rejects.toThrow(
'Log writer not open'
);
});
it('should throw when writing to stderr after close', async () => {
await writer.open();
await writer.close();
await expect(writer.writeStderr('test')).rejects.toThrow(
'Log writer not open'
);
});
});
describe('append mode', () => {
it('should append to existing log files', async () => {
// First write session
await writer.open();
await writer.writeStdout('First session\n');
await writer.close();
// Second write session with new writer
const writer2 = new ProcessLogWriter(processId, manager);
await writer2.open();
await writer2.writeStdout('Second session\n');
await writer2.close();
const content = await readFile(
manager.getLogPath(processId, 'stdout'),
'utf-8'
);
expect(content).toContain('First session');
expect(content).toContain('Second session');
});
});
});