Files
Codewalkers/apps/server/logging/writer.test.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

396 lines
12 KiB
TypeScript

/**
* 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<LogEntryEvent>('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<LogEntryEvent>('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<LogEntryEvent>('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<LogEntryEvent>('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<LogEntryEvent>('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');
});
});
});