FABRIC/src/tailer.test.ts
jeda 57e8193f7b feat(bd-2kf): Add comprehensive test coverage for parser and store
- Add 36 parser tests covering:
  - parseLogLine with valid/invalid inputs
  - parseLogLines for multi-line parsing
  - formatEvent with all options
  - Edge cases: malformed JSON, missing fields, colorization

- Add 35 store tests covering:
  - InMemoryEventStore add/query operations
  - Worker status tracking (active/idle/error)
  - Event filtering by worker, level, bead, timestamp
  - maxEvents limit and LRU trimming
  - getStore/resetStore singleton management

- Close phase beads (bd-2pa, bd-n8l, bd-2nu) as infrastructure complete
- Close test beads (bd-5eh, bd-2en) with comprehensive coverage
- Total: 91 tests passing across parser, store, and tailer

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-03 10:43:24 +00:00

364 lines
10 KiB
TypeScript

/**
* Tests for FABRIC Log Tailer
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { LogTailer } from './tailer.js';
describe('LogTailer', () => {
let tempDir: string;
let logFile: string;
beforeEach(() => {
// Create temp directory and file
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-test-'));
logFile = path.join(tempDir, 'test.log');
});
afterEach(() => {
// Cleanup temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('constructor', () => {
it('should expand ~ to home directory', () => {
const tailer = new LogTailer({ path: '~/test.log' });
expect(tailer).toBeDefined();
});
it('should accept absolute paths', () => {
const tailer = new LogTailer({ path: '/var/log/test.log' });
expect(tailer).toBeDefined();
});
it('should default parseJson to true', () => {
const tailer = new LogTailer({ path: logFile });
expect(tailer).toBeDefined();
});
it('should default follow to true', () => {
const tailer = new LogTailer({ path: logFile });
expect(tailer).toBeDefined();
});
it('should default lines to 0', () => {
const tailer = new LogTailer({ path: logFile });
expect(tailer).toBeDefined();
});
});
describe('start', () => {
it('should emit error when file does not exist', async () => {
const tailer = new LogTailer({ path: '/nonexistent/path/test.log' });
const errorPromise = new Promise<Error>((resolve) => {
tailer.on('error', resolve);
});
tailer.start();
const err = await errorPromise;
expect(err.message).toContain('Log file not found');
});
it('should start successfully when file exists', () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: false });
// Should not throw
tailer.start();
// If we get here without error, the test passes
expect(true).toBe(true);
});
});
describe('event parsing', () => {
it('should emit parsed events for valid JSON lines', async () => {
const event = {
ts: Date.now(),
worker: 'w-test',
level: 'info' as const,
msg: 'Test message',
};
fs.writeFileSync(logFile, JSON.stringify(event) + '\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 10,
});
const eventPromise = new Promise<any>((resolve) => {
tailer.on('event', resolve);
});
tailer.start();
const parsed = await eventPromise;
expect(parsed.ts).toBe(event.ts);
expect(parsed.worker).toBe(event.worker);
expect(parsed.level).toBe(event.level);
expect(parsed.msg).toBe(event.msg);
});
it('should emit raw lines regardless of JSON validity', async () => {
fs.writeFileSync(logFile, 'not valid json\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 10,
parseJson: false,
});
const linePromise = new Promise<string>((resolve) => {
tailer.on('line', resolve);
});
tailer.start();
const line = await linePromise;
expect(line).toBe('not valid json');
});
it('should not emit event for invalid JSON when parseJson is true', async () => {
fs.writeFileSync(logFile, 'not valid json\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 10,
});
let eventEmitted = false;
tailer.on('event', () => {
eventEmitted = true;
});
tailer.start();
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(eventEmitted).toBe(false);
});
it('should handle empty lines gracefully', async () => {
fs.writeFileSync(logFile, '\n\n\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 10,
});
let eventCount = 0;
tailer.on('event', () => {
eventCount++;
});
tailer.start();
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 50));
expect(eventCount).toBe(0);
});
});
describe('reading existing lines', () => {
it('should read last N lines on start when lines option is set', async () => {
const events = [
{ ts: 1, worker: 'w1', level: 'info' as const, msg: 'first' },
{ ts: 2, worker: 'w2', level: 'info' as const, msg: 'second' },
{ ts: 3, worker: 'w3', level: 'info' as const, msg: 'third' },
];
fs.writeFileSync(logFile, events.map((e) => JSON.stringify(e)).join('\n') + '\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 2, // Only read last 2 lines
});
const receivedEvents: any[] = [];
const allReceived = new Promise<void>((resolve) => {
tailer.on('event', (event) => {
receivedEvents.push(event);
if (receivedEvents.length === 2) {
resolve();
}
});
});
tailer.start();
await allReceived;
// Should have received 2 events (second and third)
expect(receivedEvents.length).toBe(2);
expect(receivedEvents[0].msg).toBe('second');
expect(receivedEvents[1].msg).toBe('third');
});
it('should read all lines when lines is greater than file', async () => {
const events = [
{ ts: 1, worker: 'w1', level: 'info' as const, msg: 'first' },
{ ts: 2, worker: 'w2', level: 'info' as const, msg: 'second' },
];
fs.writeFileSync(logFile, events.map((e) => JSON.stringify(e)).join('\n') + '\n');
const tailer = new LogTailer({
path: logFile,
follow: false,
lines: 100, // More than file has
});
const receivedEvents: any[] = [];
const allReceived = new Promise<void>((resolve) => {
tailer.on('event', (event) => {
receivedEvents.push(event);
if (receivedEvents.length === 2) {
resolve();
}
});
});
tailer.start();
await allReceived;
expect(receivedEvents.length).toBe(2);
});
});
describe('stop', () => {
it('should stop watching file', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
const endPromise = new Promise<void>((resolve) => {
tailer.on('end', resolve);
});
tailer.start();
// Give it time to start watching
await new Promise((resolve) => setTimeout(resolve, 50));
tailer.stop();
await endPromise;
expect(tailer.isActive).toBe(false);
});
it('should emit end event when stopped', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
const endPromise = new Promise<void>((resolve) => {
tailer.on('end', resolve);
});
tailer.start();
await new Promise((resolve) => setTimeout(resolve, 50));
tailer.stop();
await endPromise;
});
});
describe('follow mode', () => {
it('should detect new content appended to file', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
const event = {
ts: Date.now(),
worker: 'w-test',
level: 'info' as const,
msg: 'new event',
};
const eventPromise = new Promise<any>((resolve) => {
tailer.on('event', resolve);
});
tailer.start();
// Append to file after a short delay
await new Promise((resolve) => setTimeout(resolve, 100));
fs.appendFileSync(logFile, JSON.stringify(event) + '\n');
const parsed = await eventPromise;
expect(parsed.msg).toBe('new event');
tailer.stop();
});
it('should handle multiple events appended', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
const events = [
{ ts: 1, worker: 'w1', level: 'info' as const, msg: 'first' },
{ ts: 2, worker: 'w2', level: 'info' as const, msg: 'second' },
];
const receivedEvents: any[] = [];
const allEventsPromise = new Promise<void>((resolve) => {
tailer.on('event', (event) => {
receivedEvents.push(event);
if (receivedEvents.length === 2) {
resolve();
}
});
});
tailer.start();
// Append events after a short delay
await new Promise((resolve) => setTimeout(resolve, 100));
fs.appendFileSync(logFile, events.map((e) => JSON.stringify(e)).join('\n') + '\n');
await allEventsPromise;
expect(receivedEvents[0].msg).toBe('first');
expect(receivedEvents[1].msg).toBe('second');
tailer.stop();
});
});
describe('isActive', () => {
it('should be false before start', () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
expect(tailer.isActive).toBe(false);
});
it('should be true after start in follow mode', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
tailer.start();
// Give it a moment to set up the watcher
await new Promise((resolve) => setTimeout(resolve, 50));
expect(tailer.isActive).toBe(true);
tailer.stop();
});
it('should be false after stop', async () => {
fs.writeFileSync(logFile, '');
const tailer = new LogTailer({ path: logFile, follow: true });
const endPromise = new Promise<void>((resolve) => {
tailer.on('end', resolve);
});
tailer.start();
await new Promise((resolve) => setTimeout(resolve, 50));
tailer.stop();
await endPromise;
expect(tailer.isActive).toBe(false);
});
});
});