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:
215
src/logging/manager.test.ts
Normal file
215
src/logging/manager.test.ts
Normal 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
265
src/logging/writer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user