feat(bd-29t): E2E test: ActivityStream displays scrolling log entries

Comprehensive E2E test suite covering:
- Chronological order display (100+ events)
- Timestamp formatting and display
- Level colors for debug/info/warn/error
- Scrolling behavior with maxLines buffer
- Filtering by worker, level, search term, time range
- Pause/resume workflow
- High volume event handling (1000+ events)

All 36 tests passing.

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-05 03:54:31 +00:00
parent e7bc81d239
commit d0df0954cb

View file

@ -0,0 +1,762 @@
/**
* E2E Test: ActivityStream Display and Scrolling
*
* Verifies that ActivityStream displays log entries in chronological order
* with proper timestamps, level colors, and filtering behavior.
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import blessed from 'blessed';
// Mock the blessed module before importing ActivityStream
vi.mock('blessed', () => {
// Create the mock log instance
const mockLogInstance = {
setContent: vi.fn(),
log: vi.fn(),
setLabel: vi.fn(),
focus: vi.fn(),
key: vi.fn(),
screen: {
render: vi.fn(),
},
};
const mockLog = vi.fn(() => mockLogInstance);
return {
default: {
log: mockLog,
},
log: mockLog,
};
});
// Import after mocking
import { ActivityStream } from './ActivityStream.js';
import { LogEvent } from '../../types.js';
// Helper to create mock LogEvent
function createMockEvent(overrides: Partial<LogEvent> = {}): LogEvent {
return {
ts: Date.now(),
worker: 'w-test123',
level: 'info',
msg: 'Test event message',
...overrides,
};
}
// Helper to create mock screen
function createMockScreen() {
return {
render: vi.fn(),
append: vi.fn(),
key: vi.fn(),
destroy: vi.fn(),
} as unknown as blessed.Widgets.Screen;
}
describe('E2E: ActivityStream Display and Scrolling', () => {
let activityStream: ActivityStream;
let mockScreen: blessed.Widgets.Screen;
let mockLogInstance: any;
beforeEach(() => {
vi.clearAllMocks();
mockScreen = createMockScreen();
// Get the mock log instance from the mock
const blessedMock = blessed as unknown as { log: Mock };
mockLogInstance = blessedMock.log();
activityStream = new ActivityStream({
parent: mockScreen,
top: 0,
right: 0,
width: '50%',
bottom: 0,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('chronological order display', () => {
it('should display events in chronological order', () => {
const baseTime = Date.now();
// Add events with sequential timestamps
const events = [
createMockEvent({ ts: baseTime, msg: 'First event' }),
createMockEvent({ ts: baseTime + 1000, msg: 'Second event' }),
createMockEvent({ ts: baseTime + 2000, msg: 'Third event' }),
createMockEvent({ ts: baseTime + 3000, msg: 'Fourth event' }),
];
for (const event of events) {
activityStream.addEvent(event);
}
// Verify all events were logged in order
expect(mockLogInstance.log).toHaveBeenCalledTimes(4);
const calls = mockLogInstance.log.mock.calls;
expect(calls[0][0]).toContain('First event');
expect(calls[1][0]).toContain('Second event');
expect(calls[2][0]).toContain('Third event');
expect(calls[3][0]).toContain('Fourth event');
});
it('should maintain order when events arrive out of timestamp order', () => {
const baseTime = Date.now();
// Add events in non-chronological order
activityStream.addEvent(createMockEvent({ ts: baseTime + 2000, msg: 'Third by time' }));
activityStream.addEvent(createMockEvent({ ts: baseTime, msg: 'First by time' }));
activityStream.addEvent(createMockEvent({ ts: baseTime + 3000, msg: 'Fourth by time' }));
activityStream.addEvent(createMockEvent({ ts: baseTime + 1000, msg: 'Second by time' }));
// Should display in arrival order, not timestamp order
const calls = mockLogInstance.log.mock.calls;
expect(calls[0][0]).toContain('Third by time');
expect(calls[1][0]).toContain('First by time');
expect(calls[2][0]).toContain('Fourth by time');
expect(calls[3][0]).toContain('Second by time');
});
it('should display 100+ events in order', () => {
const baseTime = Date.now();
// Add many events
for (let i = 0; i < 150; i++) {
activityStream.addEvent(createMockEvent({
ts: baseTime + i * 100,
msg: `Event ${i}`,
}));
}
// Verify correct number of calls
expect(mockLogInstance.log).toHaveBeenCalledTimes(150);
// Check first few and last few
const calls = mockLogInstance.log.mock.calls;
expect(calls[0][0]).toContain('Event 0');
expect(calls[1][0]).toContain('Event 1');
expect(calls[148][0]).toContain('Event 148');
expect(calls[149][0]).toContain('Event 149');
});
});
describe('timestamp display', () => {
it('should include formatted timestamp in each entry', () => {
const timestamp = new Date('2024-03-05T12:30:45.000Z').getTime();
const event = createMockEvent({ ts: timestamp });
activityStream.addEvent(event);
const loggedContent = mockLogInstance.log.mock.calls[0][0];
// Should contain formatted time (format varies by locale)
expect(loggedContent).toBeDefined();
expect(typeof loggedContent).toBe('string');
});
it('should display different timestamps for different events', () => {
const time1 = new Date('2024-03-05T12:00:00.000Z').getTime();
const time2 = new Date('2024-03-05T13:00:00.000Z').getTime();
activityStream.addEvent(createMockEvent({ ts: time1, msg: 'Morning event' }));
activityStream.addEvent(createMockEvent({ ts: time2, msg: 'Afternoon event' }));
const calls = mockLogInstance.log.mock.calls;
const firstLog = calls[0][0];
const secondLog = calls[1][0];
// Timestamps should be different (specific format depends on locale)
expect(firstLog).toBeDefined();
expect(secondLog).toBeDefined();
expect(firstLog).toContain('Morning event');
expect(secondLog).toContain('Afternoon event');
});
it('should handle timestamps at midnight', () => {
const midnight = new Date('2024-03-05T00:00:00.000Z').getTime();
const event = createMockEvent({ ts: midnight, msg: 'Midnight event' });
expect(() => activityStream.addEvent(event)).not.toThrow();
expect(mockLogInstance.log).toHaveBeenCalled();
});
it('should handle timestamps with millisecond precision', () => {
const preciseTime = new Date('2024-03-05T12:30:45.123Z').getTime();
const event = createMockEvent({ ts: preciseTime, msg: 'Precise event' });
expect(() => activityStream.addEvent(event)).not.toThrow();
expect(mockLogInstance.log).toHaveBeenCalled();
});
});
describe('level colors', () => {
it('should display DEBUG level with correct formatting', () => {
activityStream.addEvent(createMockEvent({ level: 'debug', msg: 'Debug message' }));
const loggedContent = mockLogInstance.log.mock.calls[0][0];
expect(loggedContent).toContain('DEBUG');
});
it('should display INFO level with correct formatting', () => {
activityStream.addEvent(createMockEvent({ level: 'info', msg: 'Info message' }));
const loggedContent = mockLogInstance.log.mock.calls[0][0];
expect(loggedContent).toContain('INFO');
});
it('should display WARN level with correct formatting', () => {
activityStream.addEvent(createMockEvent({ level: 'warn', msg: 'Warning message' }));
const loggedContent = mockLogInstance.log.mock.calls[0][0];
expect(loggedContent).toContain('WARN');
});
it('should display ERROR level with correct formatting', () => {
activityStream.addEvent(createMockEvent({ level: 'error', msg: 'Error message' }));
const loggedContent = mockLogInstance.log.mock.calls[0][0];
expect(loggedContent).toContain('ERROR');
});
it('should differentiate between log levels visually', () => {
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['debug', 'info', 'warn', 'error'];
for (const level of levels) {
activityStream.addEvent(createMockEvent({ level, msg: `${level} message` }));
}
const calls = mockLogInstance.log.mock.calls;
// Each level should have its level name in uppercase
expect(calls[0][0]).toContain('DEBUG');
expect(calls[1][0]).toContain('INFO');
expect(calls[2][0]).toContain('WARN');
expect(calls[3][0]).toContain('ERROR');
// Each should contain its message
expect(calls[0][0]).toContain('debug message');
expect(calls[1][0]).toContain('info message');
expect(calls[2][0]).toContain('warn message');
expect(calls[3][0]).toContain('error message');
});
it('should apply color tags for levels', () => {
activityStream.addEvent(createMockEvent({ level: 'error', msg: 'Critical error' }));
const loggedContent = mockLogInstance.log.mock.calls[0][0];
// Should contain blessed color tags (format: {color-fg})
expect(loggedContent).toMatch(/\{[a-z]+-fg\}/);
});
});
describe('scrolling behavior', () => {
it('should be created with scrollable options', () => {
const blessedMock = blessed as unknown as { log: Mock };
expect(blessedMock.log).toHaveBeenCalledWith(
expect.objectContaining({
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
})
);
});
it('should handle large number of events without error', () => {
// Add many events to test scrolling buffer
for (let i = 0; i < 1000; i++) {
activityStream.addEvent(createMockEvent({ msg: `Event ${i}` }));
}
// Should trim to maxLines (default 500)
expect(activityStream.getEventsCount()).toBe(500);
});
it('should respect custom maxLines option', () => {
const smallStream = new ActivityStream({
parent: mockScreen,
top: 0,
right: 0,
width: '50%',
bottom: 0,
maxLines: 10,
});
// Add more events than maxLines
for (let i = 0; i < 20; i++) {
smallStream.addEvent(createMockEvent({ msg: `Event ${i}` }));
}
// Should trim to maxLines
expect(smallStream.getEventsCount()).toBe(10);
});
it('should keep newest events when trimming', () => {
const smallStream = new ActivityStream({
parent: mockScreen,
top: 0,
right: 0,
width: '50%',
bottom: 0,
maxLines: 5,
});
// Add 10 events
for (let i = 0; i < 10; i++) {
smallStream.addEvent(createMockEvent({ msg: `Event ${i}` }));
}
// Should have kept only the last 5
vi.clearAllMocks();
// Trigger re-render to see what's in the buffer
smallStream.setFilter({});
const calls = mockLogInstance.log.mock.calls;
// Should contain events 5-9
expect(calls.some((call: unknown[]) => String(call[0]).includes('Event 5'))).toBe(true);
expect(calls.some((call: unknown[]) => String(call[0]).includes('Event 9'))).toBe(true);
// Should NOT contain events 0-4
expect(calls.some((call: unknown[]) => String(call[0]).includes('Event 0'))).toBe(false);
expect(calls.some((call: unknown[]) => String(call[0]).includes('Event 4'))).toBe(false);
});
it('should continue scrolling when new events arrive', () => {
// Add initial batch
for (let i = 0; i < 10; i++) {
activityStream.addEvent(createMockEvent({ msg: `Event ${i}` }));
}
vi.clearAllMocks();
// Add more events
activityStream.addEvent(createMockEvent({ msg: 'New event' }));
// Should have logged the new event
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
expect(mockLogInstance.log).toHaveBeenCalledWith(expect.stringContaining('New event'));
});
it('should not scroll when paused', () => {
// Add initial events
activityStream.addEvent(createMockEvent({ msg: 'Before pause' }));
// Pause
activityStream.togglePause();
vi.clearAllMocks();
// Add new event
activityStream.addEvent(createMockEvent({ msg: 'During pause' }));
// Should NOT have logged (paused)
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should resume scrolling after unpause', () => {
activityStream.togglePause();
activityStream.addEvent(createMockEvent({ msg: 'During pause' }));
activityStream.togglePause();
vi.clearAllMocks();
// Add event after unpause
activityStream.addEvent(createMockEvent({ msg: 'After unpause' }));
// Should log normally
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
});
});
describe('filtering behavior', () => {
it('should filter by worker ID', () => {
activityStream.setFilter({ workerId: 'w-alpha' });
// Add matching event
activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Alpha message' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching event
activityStream.addEvent(createMockEvent({ worker: 'w-beta', msg: 'Beta message' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by log level', () => {
activityStream.setFilter({ level: 'error' });
// Add matching event
activityStream.addEvent(createMockEvent({ level: 'error', msg: 'Error message' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching events
activityStream.addEvent(createMockEvent({ level: 'info', msg: 'Info message' }));
activityStream.addEvent(createMockEvent({ level: 'warn', msg: 'Warn message' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by search term (message)', () => {
activityStream.setFilter({ search: 'critical' });
// Add matching event
activityStream.addEvent(createMockEvent({ msg: 'This is a critical error' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching event
activityStream.addEvent(createMockEvent({ msg: 'Normal operation' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by search term (worker)', () => {
activityStream.setFilter({ search: 'alpha' });
// Add matching event
activityStream.addEvent(createMockEvent({ worker: 'w-alpha-123', msg: 'Test' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching event
activityStream.addEvent(createMockEvent({ worker: 'w-beta-456', msg: 'Test' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by search term (tool)', () => {
activityStream.setFilter({ search: 'read' });
// Add matching event
activityStream.addEvent(createMockEvent({ tool: 'Read', msg: 'File read' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching event
activityStream.addEvent(createMockEvent({ tool: 'Write', msg: 'File write' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by search term (bead)', () => {
activityStream.setFilter({ search: 'abc' });
// Add matching event
activityStream.addEvent(createMockEvent({ bead: 'bd-abc123', msg: 'Test' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add non-matching event
activityStream.addEvent(createMockEvent({ bead: 'bd-xyz789', msg: 'Test' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter case-insensitively', () => {
activityStream.setFilter({ search: 'ERROR' });
// Add matching event with different case
activityStream.addEvent(createMockEvent({ msg: 'error occurred' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
});
it('should combine multiple filters (AND logic)', () => {
activityStream.setFilter({
workerId: 'w-alpha',
level: 'error',
search: 'critical',
});
// Add event matching all criteria
activityStream.addEvent(createMockEvent({
worker: 'w-alpha',
level: 'error',
msg: 'Critical failure',
}));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add events matching only some criteria
activityStream.addEvent(createMockEvent({
worker: 'w-alpha',
level: 'error',
msg: 'Normal error',
})); // Missing 'critical'
expect(mockLogInstance.log).not.toHaveBeenCalled();
activityStream.addEvent(createMockEvent({
worker: 'w-alpha',
level: 'info',
msg: 'Critical info',
})); // Wrong level
expect(mockLogInstance.log).not.toHaveBeenCalled();
activityStream.addEvent(createMockEvent({
worker: 'w-beta',
level: 'error',
msg: 'Critical failure',
})); // Wrong worker
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by time range (since)', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
activityStream.setFilter({ since: fiveMinutesAgo });
// Add recent event (should pass)
activityStream.addEvent(createMockEvent({ ts: now, msg: 'Recent event' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add old event (should be filtered)
activityStream.addEvent(createMockEvent({ ts: fiveMinutesAgo - 1000, msg: 'Old event' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by time range (until)', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
activityStream.setFilter({ until: fiveMinutesAgo });
// Add old event (should pass)
activityStream.addEvent(createMockEvent({ ts: fiveMinutesAgo - 1000, msg: 'Old event' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add recent event (should be filtered)
activityStream.addEvent(createMockEvent({ ts: now, msg: 'Recent event' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should filter by time range (since and until)', () => {
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
const fiveMinutesAgo = now - 5 * 60 * 1000;
activityStream.setFilter({ since: tenMinutesAgo, until: fiveMinutesAgo });
// Add event in range (should pass)
activityStream.addEvent(createMockEvent({ ts: tenMinutesAgo + 1000, msg: 'In range' }));
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Add event too old (should be filtered)
activityStream.addEvent(createMockEvent({ ts: tenMinutesAgo - 1000, msg: 'Too old' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
// Add event too recent (should be filtered)
activityStream.addEvent(createMockEvent({ ts: now, msg: 'Too recent' }));
expect(mockLogInstance.log).not.toHaveBeenCalled();
});
it('should re-render existing events when filter changes', () => {
// Add events before filtering
activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Alpha 1' }));
activityStream.addEvent(createMockEvent({ worker: 'w-beta', msg: 'Beta 1' }));
activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Alpha 2' }));
vi.clearAllMocks();
// Apply filter
activityStream.setFilter({ workerId: 'w-alpha' });
// Should have called setContent (to clear) and log (to re-add filtered events)
expect(mockLogInstance.setContent).toHaveBeenCalledWith('');
expect(mockLogInstance.log).toHaveBeenCalledTimes(2); // Two alpha events
});
it('should show all events when filter is cleared', () => {
// Add events
activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Alpha' }));
activityStream.addEvent(createMockEvent({ worker: 'w-beta', msg: 'Beta' }));
// Apply filter
activityStream.setFilter({ workerId: 'w-alpha' });
vi.clearAllMocks();
// Clear filter
activityStream.clearFilter();
// Should re-render all events
expect(mockLogInstance.setContent).toHaveBeenCalledWith('');
expect(mockLogInstance.log).toHaveBeenCalledTimes(2); // Both events
});
});
describe('complete display workflow', () => {
it('should handle realistic event stream with all features', () => {
const baseTime = Date.now();
// Simulate realistic event stream
const events = [
createMockEvent({
ts: baseTime,
worker: 'w-alpha-001',
level: 'info',
msg: 'Worker started',
}),
createMockEvent({
ts: baseTime + 100,
worker: 'w-alpha-001',
level: 'debug',
msg: 'Loading configuration',
tool: 'Read',
}),
createMockEvent({
ts: baseTime + 200,
worker: 'w-alpha-001',
level: 'info',
msg: 'Processing bead',
bead: 'bd-abc123',
}),
createMockEvent({
ts: baseTime + 300,
worker: 'w-beta-002',
level: 'warn',
msg: 'Retry attempt',
bead: 'bd-xyz789',
}),
createMockEvent({
ts: baseTime + 400,
worker: 'w-alpha-001',
level: 'error',
msg: 'Operation failed',
tool: 'Write',
bead: 'bd-abc123',
}),
createMockEvent({
ts: baseTime + 500,
worker: 'w-beta-002',
level: 'info',
msg: 'Task completed',
bead: 'bd-xyz789',
}),
];
// Add all events
for (const event of events) {
activityStream.addEvent(event);
}
// Verify all were logged
expect(mockLogInstance.log).toHaveBeenCalledTimes(6);
vi.clearAllMocks();
// Filter for errors only
activityStream.setFilter({ level: 'error' });
// Should show only the error event
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
expect(mockLogInstance.log).toHaveBeenCalledWith(
expect.stringContaining('Operation failed')
);
vi.clearAllMocks();
// Filter for specific bead
activityStream.setFilter({ search: 'bd-abc123' });
// Should show events for that bead
expect(mockLogInstance.log).toHaveBeenCalledTimes(2);
vi.clearAllMocks();
// Filter for specific worker
activityStream.setFilter({ workerId: 'w-beta-002' });
// Should show events for that worker
expect(mockLogInstance.log).toHaveBeenCalledTimes(2);
vi.clearAllMocks();
// Clear filter
activityStream.clearFilter();
// Should show all events again
expect(mockLogInstance.log).toHaveBeenCalledTimes(6);
});
it('should handle pause, filter, and resume workflow', () => {
// Add initial events
activityStream.addEvent(createMockEvent({ msg: 'Event 1' }));
activityStream.addEvent(createMockEvent({ msg: 'Event 2' }));
// Pause
activityStream.togglePause();
vi.clearAllMocks();
// Add events while paused
activityStream.addEvent(createMockEvent({ msg: 'Event 3' }));
activityStream.addEvent(createMockEvent({ msg: 'Event 4' }));
// Events should not be displayed
expect(mockLogInstance.log).not.toHaveBeenCalled();
// Apply filter
activityStream.setFilter({ search: 'Event 3' });
// Should show matching events from buffer
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// Unpause
activityStream.togglePause();
// Add new event
activityStream.addEvent(createMockEvent({ msg: 'Event 3 again' }));
// Should display (matches filter and unpaused)
expect(mockLogInstance.log).toHaveBeenCalledTimes(1);
});
it('should maintain performance with high event volume', () => {
const startTime = Date.now();
// Add 1000 events rapidly
for (let i = 0; i < 1000; i++) {
activityStream.addEvent(createMockEvent({
ts: startTime + i,
msg: `High volume event ${i}`,
}));
}
// Should complete without hanging
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete in reasonable time (less than 1 second)
expect(duration).toBeLessThan(1000);
// Should have trimmed to maxLines
expect(activityStream.getEventsCount()).toBe(500);
});
});
});