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
396 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|