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>
507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
/**
|
|
* Tests for WorkerGrid Component
|
|
*
|
|
* Tests the worker grid display with mocked blessed elements.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
|
import blessed from 'blessed';
|
|
|
|
// Mock the blessed module before importing WorkerGrid
|
|
vi.mock('blessed', () => {
|
|
// Create the mock box inside the factory
|
|
const mockBoxInstance = {
|
|
setContent: vi.fn(),
|
|
focus: vi.fn(),
|
|
key: vi.fn(),
|
|
screen: {
|
|
render: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockBox = vi.fn(() => mockBoxInstance);
|
|
|
|
return {
|
|
default: {
|
|
box: mockBox,
|
|
},
|
|
box: mockBox,
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
import { WorkerGrid } from './WorkerGrid.js';
|
|
import { WorkerInfo } 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 screen
|
|
function createMockScreen() {
|
|
return {
|
|
render: vi.fn(),
|
|
append: vi.fn(),
|
|
key: vi.fn(),
|
|
destroy: vi.fn(),
|
|
} as unknown as blessed.Widgets.Screen;
|
|
}
|
|
|
|
describe('WorkerGrid', () => {
|
|
let workerGrid: WorkerGrid;
|
|
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();
|
|
|
|
workerGrid = new WorkerGrid({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
bottom: 0,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create a blessed box with correct options', () => {
|
|
const blessedMock = blessed as unknown as { box: Mock };
|
|
expect(blessedMock.box).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
bottom: 0,
|
|
label: ' Workers ',
|
|
scrollable: true,
|
|
alwaysScroll: true,
|
|
keys: true,
|
|
vi: true,
|
|
mouse: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should bind key handlers on construction', () => {
|
|
// Key bindings should be registered
|
|
expect(mockBoxInstance.key).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('updateWorkers', () => {
|
|
it('should update workers list and render', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-abc123', status: 'active' }),
|
|
createMockWorker({ id: 'w-def456', status: 'idle' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
expect(mockBoxInstance.setContent).toHaveBeenCalled();
|
|
// Component calls this.box.screen.render() which is the box's internal screen reference
|
|
expect(mockBoxInstance.screen.render).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show "No workers detected" when empty', () => {
|
|
workerGrid.updateWorkers([]);
|
|
|
|
expect(mockBoxInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('No workers detected')
|
|
);
|
|
});
|
|
|
|
it('should show worker count in header', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-abc123' }),
|
|
createMockWorker({ id: 'w-def456' }),
|
|
createMockWorker({ id: 'w-ghi789' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
expect(mockBoxInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('Total: 3 workers')
|
|
);
|
|
});
|
|
|
|
it('should reset selected index if out of bounds', () => {
|
|
// First set some workers
|
|
workerGrid.updateWorkers([
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
]);
|
|
|
|
// Update to fewer workers
|
|
workerGrid.updateWorkers([createMockWorker({ id: 'w-1' })]);
|
|
|
|
// Should not throw and selection should be valid
|
|
const selected = workerGrid.getSelected();
|
|
expect(selected).toBeDefined();
|
|
expect(selected?.id).toBe('w-1');
|
|
});
|
|
});
|
|
|
|
describe('selectNext', () => {
|
|
it('should move to next worker', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
createMockWorker({ id: 'w-3' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// Initially selected is first worker
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-2');
|
|
});
|
|
|
|
it('should wrap to first worker when at end', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// Move to last
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-2');
|
|
|
|
// Wrap to first
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
});
|
|
|
|
it('should do nothing when no workers', () => {
|
|
workerGrid.updateWorkers([]);
|
|
|
|
// Should not throw
|
|
expect(() => workerGrid.selectNext()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('selectPrevious', () => {
|
|
it('should move to previous worker', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
createMockWorker({ id: 'w-3' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// Move to second
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-2');
|
|
|
|
// Move back to first
|
|
workerGrid.selectPrevious();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
});
|
|
|
|
it('should wrap to last worker when at beginning', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// At first, wrap to last
|
|
workerGrid.selectPrevious();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-2');
|
|
});
|
|
|
|
it('should do nothing when no workers', () => {
|
|
workerGrid.updateWorkers([]);
|
|
|
|
// Should not throw
|
|
expect(() => workerGrid.selectPrevious()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getSelected', () => {
|
|
it('should return currently selected worker', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-2');
|
|
});
|
|
|
|
it('should return undefined when no workers', () => {
|
|
workerGrid.updateWorkers([]);
|
|
expect(workerGrid.getSelected()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('focus', () => {
|
|
it('should focus the box element', () => {
|
|
workerGrid.focus();
|
|
expect(mockBoxInstance.focus).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getElement', () => {
|
|
it('should return the box element', () => {
|
|
const element = workerGrid.getElement();
|
|
expect(element).toBe(mockBoxInstance);
|
|
});
|
|
});
|
|
|
|
describe('render output', () => {
|
|
it('should include worker status icons', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1', status: 'active' }),
|
|
createMockWorker({ id: 'w-2', status: 'idle' }),
|
|
createMockWorker({ id: 'w-3', status: 'error' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
|
|
// Check for status icons
|
|
expect(content).toContain('●'); // active
|
|
expect(content).toContain('○'); // idle
|
|
expect(content).toContain('✗'); // error
|
|
});
|
|
|
|
it('should render workers with correct status colors', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1', status: 'active' }),
|
|
createMockWorker({ id: 'w-2', status: 'idle' }),
|
|
createMockWorker({ id: 'w-3', status: 'error' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
|
|
// Check for status color tags
|
|
expect(content).toContain('{light-green-fg}'); // active
|
|
expect(content).toContain('{light-yellow-fg}'); // idle
|
|
expect(content).toContain('{light-red-fg}'); // error
|
|
});
|
|
|
|
it('should show collision indicator when worker has collision', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1', hasCollision: true }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('⚠');
|
|
});
|
|
|
|
it('should not show collision indicator when no collision', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1', hasCollision: false }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).not.toContain('⚠');
|
|
});
|
|
|
|
it('should truncate worker ID to 12 characters', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-verylongworkerid123456' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('w-verylongwo');
|
|
});
|
|
|
|
it('should show selection marker on selected worker', () => {
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const firstContent = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(firstContent).toContain('>'); // Selection marker
|
|
|
|
// Select second worker
|
|
workerGrid.selectNext();
|
|
|
|
const secondContent = mockBoxInstance.setContent.mock.calls[1][0];
|
|
expect(secondContent).toContain('>');
|
|
});
|
|
|
|
it('should include current task from lastEvent', () => {
|
|
const workers = [
|
|
createMockWorker({
|
|
id: 'w-1',
|
|
lastEvent: {
|
|
ts: Date.now(),
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'Processing bead',
|
|
bead: 'bd-abc123',
|
|
},
|
|
}),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('bd-abc123');
|
|
expect(content).toContain('Processing bead');
|
|
});
|
|
});
|
|
|
|
describe('key bindings', () => {
|
|
it('should bind up and k keys to selectPrevious', () => {
|
|
expect(mockBoxInstance.key).toHaveBeenCalledWith(['up', 'k'], expect.any(Function));
|
|
});
|
|
|
|
it('should bind down and j keys to selectNext', () => {
|
|
expect(mockBoxInstance.key).toHaveBeenCalledWith(['down', 'j'], expect.any(Function));
|
|
});
|
|
|
|
it('should bind g key to select first', () => {
|
|
expect(mockBoxInstance.key).toHaveBeenCalledWith(['g'], expect.any(Function));
|
|
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
createMockWorker({ id: 'w-3' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// Move to last worker
|
|
workerGrid.selectNext();
|
|
workerGrid.selectNext();
|
|
expect(workerGrid.getSelected()?.id).toBe('w-3');
|
|
|
|
// Find the 'g' handler and call it
|
|
const gCall = mockBoxInstance.key.mock.calls.find((call: unknown[]) => Array.isArray(call?.[0]) && call[0].includes('g'));
|
|
const gHandler = gCall?.[1];
|
|
if (gHandler) {
|
|
gHandler();
|
|
}
|
|
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
});
|
|
|
|
it('should bind G (shift+g) key to select last', () => {
|
|
expect(mockBoxInstance.key).toHaveBeenCalledWith(['G'], expect.any(Function));
|
|
|
|
const workers = [
|
|
createMockWorker({ id: 'w-1' }),
|
|
createMockWorker({ id: 'w-2' }),
|
|
createMockWorker({ id: 'w-3' }),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
// Initially at first
|
|
expect(workerGrid.getSelected()?.id).toBe('w-1');
|
|
|
|
// Find the 'G' handler and call it
|
|
const GCall = mockBoxInstance.key.mock.calls.find((call: unknown[]) => Array.isArray(call?.[0]) && call[0].includes('G'));
|
|
const GHandler = GCall?.[1];
|
|
if (GHandler) {
|
|
GHandler();
|
|
}
|
|
|
|
expect(workerGrid.getSelected()?.id).toBe('w-3');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle workers with no lastEvent', () => {
|
|
const workers = [
|
|
createMockWorker({
|
|
id: 'w-1',
|
|
lastEvent: undefined,
|
|
}),
|
|
];
|
|
|
|
// Should not throw
|
|
expect(() => workerGrid.updateWorkers(workers)).not.toThrow();
|
|
});
|
|
|
|
it('should handle empty message in lastEvent', () => {
|
|
const workers = [
|
|
createMockWorker({
|
|
id: 'w-1',
|
|
lastEvent: {
|
|
ts: Date.now(),
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: '',
|
|
},
|
|
}),
|
|
];
|
|
|
|
// Should not throw
|
|
expect(() => workerGrid.updateWorkers(workers)).not.toThrow();
|
|
});
|
|
|
|
it('should handle very long task descriptions', () => {
|
|
const workers = [
|
|
createMockWorker({
|
|
id: 'w-1',
|
|
lastEvent: {
|
|
ts: Date.now(),
|
|
worker: 'w-1',
|
|
level: 'info',
|
|
msg: 'This is a very long task description that should be truncated to 25 characters',
|
|
bead: 'bd-test',
|
|
},
|
|
}),
|
|
];
|
|
|
|
workerGrid.updateWorkers(workers);
|
|
|
|
const content = mockBoxInstance.setContent.mock.calls[0][0];
|
|
// Message should be truncated (25 chars)
|
|
expect(content).toContain('This is a very long ta');
|
|
});
|
|
});
|
|
});
|