Fix discrepancy where /api/workers returned contradictory data: - beadsCompleted: 285 (counts bead.released events including timed-out) - stuck: true, stuckReason: 'Running for 2311m with only 1 completion(s)' The stuck detection now correctly uses: - beadsCompleted: all beads processed (including timed-out/deferred) - beadsSucceeded: only successful completions (bead.completed events) - beadsTimedOut: new counter for timed-out/deferred beads Changes: - Add beadsTimedOut counter to WorkerInfo type - Increment beadsTimedOut on bead.released with TimedOut/Deferred outcome - Update stuck detection to show clear reason text: - 'X processed but 0 successful completions (all timed out/deferred)' - 'X processed but only Y successful completion(s) (Z timed out/deferred)' - Add beadsTimedOut to evidence array Fix acceptance criteria: - Worker processing 100 timed-out beads shows clearly in UI: - 100 beads completed - 0 beads succeeded - Stuck reason: '100 processed but 0 successful completions' Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
/**
|
|
* E2E Test: WorkerDetail Panel
|
|
*
|
|
* Verifies that WorkerDetail panel shows correct information for a selected
|
|
* worker including status, uptime, beads completed, and recent events.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
|
import blessed from 'blessed';
|
|
|
|
// Mock the blessed module before importing WorkerDetail
|
|
vi.mock('blessed', () => {
|
|
const mockBoxInstance = {
|
|
setContent: vi.fn(),
|
|
focus: vi.fn(),
|
|
key: vi.fn(),
|
|
show: vi.fn(),
|
|
hide: vi.fn(),
|
|
hidden: true,
|
|
screen: {
|
|
render: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockBox = vi.fn(() => mockBoxInstance);
|
|
|
|
return {
|
|
default: {
|
|
box: mockBox,
|
|
},
|
|
box: mockBox,
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
import { WorkerDetail } from './WorkerDetail.js';
|
|
import { WorkerInfo, LogEvent } from '../../types.js';
|
|
|
|
// Helper to create mock WorkerInfo
|
|
function createMockWorker(overrides: Partial<WorkerInfo> = {}): WorkerInfo {
|
|
return {
|
|
id: 'w-test123',
|
|
status: 'active',
|
|
beadsCompleted: 5,
|
|
beadsSucceeded: 3,
|
|
beadsTimedOut: 2,
|
|
firstSeen: Date.now() - 60000,
|
|
lastActivity: Date.now(),
|
|
activeFiles: [],
|
|
hasCollision: false,
|
|
activeDirectories: [],
|
|
collisionTypes: [],
|
|
eventCount: 10,
|
|
currentBead: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// 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: WorkerDetail Panel', () => {
|
|
let detail: WorkerDetail;
|
|
let mockScreen: blessed.Widgets.Screen;
|
|
let mockBoxInstance: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockScreen = createMockScreen();
|
|
|
|
// Get the mock box instance from the mock
|
|
const blessedMock = blessed as unknown as { box: Mock };
|
|
mockBoxInstance = blessedMock.box();
|
|
|
|
detail = new WorkerDetail({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: '50%',
|
|
width: '50%',
|
|
height: '100%',
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function getRenderedContent(): string {
|
|
const calls = mockBoxInstance.setContent.mock.calls;
|
|
return calls[calls.length - 1][0];
|
|
}
|
|
|
|
describe('no worker selected', () => {
|
|
it('should show placeholder when no worker is set', () => {
|
|
detail.render();
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toBe('{gray-fg}No worker selected{/}');
|
|
});
|
|
|
|
it('should re-show placeholder when worker is set to null', () => {
|
|
detail.setWorker(createMockWorker());
|
|
detail.setWorker(null);
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toBe('{gray-fg}No worker selected{/}');
|
|
});
|
|
});
|
|
|
|
describe('worker status display', () => {
|
|
it('should show active status with green color and filled circle', () => {
|
|
detail.setWorker(createMockWorker({ id: 'w-active-1', status: 'active' }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{light-green-fg}{bold}● w-active-1{/}');
|
|
expect(content).toContain('State:');
|
|
expect(content).toContain('{light-green-fg}ACTIVE{/}');
|
|
});
|
|
|
|
it('should show idle status with yellow color and hollow circle', () => {
|
|
detail.setWorker(createMockWorker({ id: 'w-idle-1', status: 'idle' }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{light-yellow-fg}{bold}○ w-idle-1{/}');
|
|
expect(content).toContain('{light-yellow-fg}IDLE{/}');
|
|
});
|
|
|
|
it('should show error status with red color and X mark', () => {
|
|
detail.setWorker(createMockWorker({ id: 'w-error-1', status: 'error' }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{light-red-fg}{bold}✗ w-error-1{/}');
|
|
expect(content).toContain('{light-red-fg}ERROR{/}');
|
|
});
|
|
});
|
|
|
|
describe('uptime display', () => {
|
|
it('should show uptime in seconds for recent workers', () => {
|
|
detail.setWorker(createMockWorker({ firstSeen: Date.now() - 30000 }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Uptime:');
|
|
expect(content).toMatch(/\d+s$/m);
|
|
});
|
|
|
|
it('should show uptime in minutes and seconds', () => {
|
|
detail.setWorker(createMockWorker({ firstSeen: Date.now() - 150000 }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toMatch(/\d+m \d+s$/m);
|
|
});
|
|
|
|
it('should show uptime in hours and minutes for long-running workers', () => {
|
|
detail.setWorker(createMockWorker({ firstSeen: Date.now() - 7200000 }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toMatch(/\d+h \d+m$/m);
|
|
});
|
|
});
|
|
|
|
describe('beads completed display', () => {
|
|
it('should display beads completed count with green color', () => {
|
|
detail.setWorker(createMockWorker({
|
|
beadsCompleted: 42,
|
|
beadsSucceeded: 3,
|
|
beadsTimedOut: 2,
|
|
}));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Beads Completed:');
|
|
expect(content).toContain('{green-fg}42{/}');
|
|
});
|
|
|
|
it('should display zero beads completed', () => {
|
|
detail.setWorker(createMockWorker({
|
|
beadsCompleted: 0,
|
|
beadsSucceeded: 0,
|
|
beadsTimedOut: 0,
|
|
}));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{green-fg}0{/}');
|
|
});
|
|
});
|
|
|
|
describe('last activity display', () => {
|
|
it('should show last activity section header', () => {
|
|
detail.setWorker(createMockWorker());
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Last Activity:');
|
|
});
|
|
|
|
it('should show no events message when worker has no last event', () => {
|
|
detail.setWorker(createMockWorker({ lastEvent: undefined }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('No events recorded');
|
|
});
|
|
|
|
it('should show last event details with time and level', () => {
|
|
const event = createMockEvent({
|
|
ts: Date.now(),
|
|
level: 'info',
|
|
msg: 'Processing bead bd-abc',
|
|
});
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Time:');
|
|
expect(content).toContain('Level:');
|
|
expect(content).toContain('{light-cyan-fg}INFO{/}');
|
|
expect(content).toContain('Msg: Processing bead bd-abc');
|
|
});
|
|
|
|
it('should show bead info when present in last event', () => {
|
|
const event = createMockEvent({ bead: 'bd-xyz789' });
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Bead:');
|
|
expect(content).toContain('{magenta-fg}bd-xyz789{/}');
|
|
});
|
|
|
|
it('should show tool info when present in last event', () => {
|
|
const event = createMockEvent({ tool: 'Read' });
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Tool:');
|
|
expect(content).toContain('{cyan-fg}Read{/}');
|
|
});
|
|
|
|
it('should show duration when present in last event', () => {
|
|
const event = createMockEvent({ duration_ms: 5000 });
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Duration:');
|
|
expect(content).toContain('5.0s');
|
|
});
|
|
|
|
it('should show error details when present in last event', () => {
|
|
const event = createMockEvent({ error: 'Connection refused' });
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{red-fg}Error: Connection refused{/}');
|
|
});
|
|
|
|
it('should truncate long messages to 60 characters', () => {
|
|
const longMsg = 'A'.repeat(100);
|
|
const event = createMockEvent({ msg: longMsg });
|
|
detail.setWorker(createMockWorker({ lastEvent: event }));
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Msg: ' + 'A'.repeat(60));
|
|
expect(content).not.toContain(longMsg);
|
|
});
|
|
});
|
|
|
|
describe('recent events display', () => {
|
|
it('should show recent events section when events are set', () => {
|
|
const events = [
|
|
createMockEvent({ msg: 'Event 1' }),
|
|
createMockEvent({ msg: 'Event 2' }),
|
|
];
|
|
detail.setWorker(createMockWorker());
|
|
detail.setRecentEvents(events);
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('Recent Events:');
|
|
expect(content).toContain('Event 1');
|
|
expect(content).toContain('Event 2');
|
|
});
|
|
|
|
it('should limit displayed events to last 10', () => {
|
|
const events = Array.from({ length: 25 }, (_, i) =>
|
|
createMockEvent({ msg: `Event ${i}` }),
|
|
);
|
|
detail.setWorker(createMockWorker());
|
|
detail.setRecentEvents(events);
|
|
|
|
const content = getRenderedContent();
|
|
|
|
// First 15 events should not appear (only last 10 displayed)
|
|
expect(content).not.toContain('Event 0');
|
|
expect(content).not.toContain('Event 14');
|
|
// Last 10 should appear
|
|
expect(content).toContain('Event 15');
|
|
expect(content).toContain('Event 24');
|
|
});
|
|
|
|
it('should store up to 20 events internally', () => {
|
|
const events = Array.from({ length: 30 }, (_, i) =>
|
|
createMockEvent({ msg: `Event ${i}` }),
|
|
);
|
|
detail.setWorker(createMockWorker());
|
|
detail.setRecentEvents(events);
|
|
|
|
// setRecentEvents stores last 20 (events 10-29), render shows last 10 of those (events 20-29)
|
|
const content = getRenderedContent();
|
|
expect(content).not.toContain('Event 9');
|
|
expect(content).not.toContain('Event 19');
|
|
expect(content).toContain('Event 20');
|
|
expect(content).toContain('Event 29');
|
|
});
|
|
|
|
it('should show event level colors', () => {
|
|
const events = [
|
|
createMockEvent({ level: 'warn', msg: 'Warning event' }),
|
|
createMockEvent({ level: 'error', msg: 'Error event' }),
|
|
];
|
|
detail.setWorker(createMockWorker());
|
|
detail.setRecentEvents(events);
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('{light-yellow-fg}WAR{/}');
|
|
expect(content).toContain('{light-red-fg}ERR{/}');
|
|
});
|
|
|
|
it('should truncate event messages to 40 characters', () => {
|
|
const longMsg = 'X'.repeat(80);
|
|
const events = [createMockEvent({ msg: longMsg })];
|
|
detail.setWorker(createMockWorker());
|
|
detail.setRecentEvents(events);
|
|
|
|
const content = getRenderedContent();
|
|
expect(content).toContain('X'.repeat(40));
|
|
expect(content).not.toContain(longMsg);
|
|
});
|
|
});
|
|
|
|
describe('visibility methods', () => {
|
|
it('should start hidden', () => {
|
|
// box.hidden=true means isVisible() returns !true = false
|
|
expect(detail.isVisible()).toBe(false);
|
|
expect(mockBoxInstance.hidden).toBe(true);
|
|
});
|
|
|
|
it('should show the panel', () => {
|
|
detail.show();
|
|
expect(mockBoxInstance.show).toHaveBeenCalled();
|
|
expect(mockBoxInstance.screen.render).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should hide the panel', () => {
|
|
detail.hide();
|
|
expect(mockBoxInstance.hide).toHaveBeenCalled();
|
|
expect(mockBoxInstance.screen.render).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should toggle visibility', () => {
|
|
detail.toggle();
|
|
expect(mockBoxInstance.show).toHaveBeenCalled();
|
|
|
|
mockBoxInstance.hidden = false;
|
|
detail.toggle();
|
|
expect(mockBoxInstance.hide).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should focus the panel', () => {
|
|
detail.focus();
|
|
expect(mockBoxInstance.focus).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('worker switching', () => {
|
|
it('should update content when switching workers', () => {
|
|
detail.setWorker(createMockWorker({ id: 'w-alpha', status: 'active' }));
|
|
let content = getRenderedContent();
|
|
expect(content).toContain('w-alpha');
|
|
expect(content).toContain('{light-green-fg}');
|
|
|
|
detail.setWorker(createMockWorker({ id: 'w-beta', status: 'error' }));
|
|
content = getRenderedContent();
|
|
expect(content).toContain('w-beta');
|
|
expect(content).toContain('{light-red-fg}');
|
|
expect(content).not.toContain('w-alpha');
|
|
});
|
|
});
|
|
|
|
describe('complete workflow', () => {
|
|
it('should render full worker detail with all fields populated', () => {
|
|
const worker = createMockWorker({
|
|
id: 'w-claude-sonnet-alpha',
|
|
status: 'active',
|
|
beadsCompleted: 23,
|
|
beadsSucceeded: 3,
|
|
beadsTimedOut: 2,
|
|
firstSeen: Date.now() - 5400000, // ~1.5h ago
|
|
lastEvent: {
|
|
ts: Date.now() - 5000,
|
|
worker: 'w-claude-sonnet-alpha',
|
|
level: 'info',
|
|
msg: 'Committed changes for bd-1j9',
|
|
bead: 'bd-1j9',
|
|
tool: 'Write',
|
|
duration_ms: 1200,
|
|
},
|
|
});
|
|
|
|
const recentEvents = [
|
|
createMockEvent({ level: 'info', msg: 'Reading source files' }),
|
|
createMockEvent({ level: 'warn', msg: 'Potential merge conflict detected' }),
|
|
createMockEvent({ level: 'info', msg: 'Writing test file' }),
|
|
];
|
|
|
|
detail.setWorker(worker);
|
|
detail.setRecentEvents(recentEvents);
|
|
|
|
const content = getRenderedContent();
|
|
|
|
// Header with status
|
|
expect(content).toContain('w-claude-sonnet-alpha');
|
|
expect(content).toContain('{light-green-fg}{bold}● w-claude-sonnet-alpha{/}');
|
|
|
|
// Status info
|
|
expect(content).toContain('ACTIVE');
|
|
expect(content).toMatch(/\d+h \d+m$/m);
|
|
expect(content).toContain('{green-fg}23{/}');
|
|
|
|
// Last activity
|
|
expect(content).toContain('Bead: {magenta-fg}bd-1j9{/}');
|
|
expect(content).toContain('Tool: {cyan-fg}Write{/}');
|
|
expect(content).toContain('Msg: Committed changes for bd-1j9');
|
|
expect(content).toContain('Duration: 1.2s');
|
|
|
|
// Recent events
|
|
expect(content).toContain('Reading source files');
|
|
expect(content).toContain('Potential merge conflict detected');
|
|
expect(content).toContain('Writing test file');
|
|
});
|
|
});
|
|
});
|