diff --git a/src/logging/manager.test.ts b/src/logging/manager.test.ts new file mode 100644 index 0000000..1030cb9 --- /dev/null +++ b/src/logging/manager.test.ts @@ -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); + }); + }); +}); diff --git a/src/logging/writer.test.ts b/src/logging/writer.test.ts new file mode 100644 index 0000000..f2c203c --- /dev/null +++ b/src/logging/writer.test.ts @@ -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'); + }); + }); +});