/** * 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, vi } 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'; import { createEventBus } from '../events/index.js'; import type { EventBus, LogEntryEvent } from '../events/index.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'); }); }); }); describe('ProcessLogWriter with EventBus', () => { let testDir: string; let manager: LogManager; let eventBus: EventBus; let writerWithBus: ProcessLogWriter; const processId = 'event-test-process'; beforeEach(async () => { testDir = join(tmpdir(), `cw-event-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); manager = new LogManager({ baseDir: testDir }); eventBus = createEventBus(); writerWithBus = new ProcessLogWriter(processId, manager, eventBus); }); afterEach(async () => { try { await writerWithBus.close(); } catch { // Ignore if already closed } try { await rm(testDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); describe('event emission', () => { it('should emit log:entry event on writeStdout', async () => { const handler = vi.fn(); eventBus.on('log:entry', handler); await writerWithBus.open(); await writerWithBus.writeStdout('Hello stdout\n'); expect(handler).toHaveBeenCalledOnce(); const event = handler.mock.calls[0][0] as LogEntryEvent; expect(event.type).toBe('log:entry'); expect(event.payload.processId).toBe(processId); expect(event.payload.stream).toBe('stdout'); expect(event.payload.data).toBe('Hello stdout\n'); expect(event.timestamp).toBeInstanceOf(Date); }); it('should emit log:entry event on writeStderr', async () => { const handler = vi.fn(); eventBus.on('log:entry', handler); await writerWithBus.open(); await writerWithBus.writeStderr('Error message\n'); expect(handler).toHaveBeenCalledOnce(); const event = handler.mock.calls[0][0] as LogEntryEvent; expect(event.type).toBe('log:entry'); expect(event.payload.processId).toBe(processId); expect(event.payload.stream).toBe('stderr'); expect(event.payload.data).toBe('Error message\n'); }); it('should emit events with Buffer data converted to string', async () => { const handler = vi.fn(); eventBus.on('log:entry', handler); await writerWithBus.open(); await writerWithBus.writeStdout(Buffer.from('Buffer data\n')); const event = handler.mock.calls[0][0] as LogEntryEvent; expect(event.payload.data).toBe('Buffer data\n'); }); it('should emit event for each write call', async () => { const handler = vi.fn(); eventBus.on('log:entry', handler); await writerWithBus.open(); await writerWithBus.writeStdout('Line 1\n'); await writerWithBus.writeStdout('Line 2\n'); await writerWithBus.writeStderr('Error\n'); expect(handler).toHaveBeenCalledTimes(3); // Verify each call expect(handler.mock.calls[0][0].payload.stream).toBe('stdout'); expect(handler.mock.calls[0][0].payload.data).toBe('Line 1\n'); expect(handler.mock.calls[1][0].payload.stream).toBe('stdout'); expect(handler.mock.calls[1][0].payload.data).toBe('Line 2\n'); expect(handler.mock.calls[2][0].payload.stream).toBe('stderr'); expect(handler.mock.calls[2][0].payload.data).toBe('Error\n'); }); }); describe('without eventBus', () => { it('should NOT emit events when eventBus is not provided', async () => { // Create a writer without eventBus const writerNoBus = new ProcessLogWriter(processId + '-nobus', manager); const handler = vi.fn(); eventBus.on('log:entry', handler); await writerNoBus.open(); await writerNoBus.writeStdout('Hello\n'); await writerNoBus.writeStderr('Error\n'); await writerNoBus.close(); // Handler should not have been called because writerNoBus has no eventBus expect(handler).not.toHaveBeenCalled(); }); }); describe('backwards compatibility', () => { it('should work with two-argument constructor', async () => { const writerCompat = new ProcessLogWriter(processId + '-compat', manager); await writerCompat.open(); await writerCompat.writeStdout('Compat test\n'); await writerCompat.close(); const content = await readFile( manager.getLogPath(processId + '-compat', 'stdout'), 'utf-8' ); expect(content).toContain('Compat test'); }); }); });