feat(bd-1j9): E2E test: WorkerDetail shows selected worker info

Added comprehensive E2E test suite for WorkerDetail component with 39 test cases covering:
- Worker status display (active, idle, error)
- Event count and current tool display
- Last seen timestamp formatting
- Recent events list rendering
- Collision alert display
- Current activity section
- CSS classes and accessibility features
- Edge cases and error handling

All tests passing.

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-05 03:57:08 +00:00
parent e28dfb6744
commit 41052cb0dd
3 changed files with 1198 additions and 1 deletions

View file

@ -1,7 +1,7 @@
{"id":"bd-129","title":"Integration test: Parse real NEEDLE worker logs end-to-end","description":"Create a vitest integration test that reads actual NEEDLE log files from ~/.needle/logs/ and verifies the parser correctly extracts worker, bead, timestamp and event information from production logs.","status":"in_progress","priority":1,"issue_type":"task","assignee":"coder","created_at":"2026-03-05T00:50:21.096072110Z","created_by":"coder","updated_at":"2026-03-05T00:56:49.112216093Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-129","depends_on_id":"bd-21r","type":"blocks","created_at":"2026-03-05T00:50:55.481211428Z","created_by":"coder","metadata":"{}","thread_id":""}]}
{"id":"bd-1f4","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 74580s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-05T00:40:29.776223963Z","created_by":"coder","updated_at":"2026-03-05T00:46:18.571209865Z","closed_at":"2026-03-05T00:46:06.130470809Z","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":10,"issue_id":"bd-1f4","author":"Jed Arden","text":"## Resolution: Expected Completion\n\nThis worker starvation alert is **LEGITIMATE** - the FABRIC project is 100% complete.\n\n**Evidence:**\n- Ready queue: 0 beads available\n- Open non-HUMAN beads: 0\n- Project completion: 100% (see ROADMAP.md)\n- All phases complete: Phase 1 (Core), Phase 2 (TUI), Phase 3 (Web), Phase 3.5 (Intelligence)\n\n**Conclusion:** No work available because there is no work to do. Project is complete.\n\nReference: worker-starvation-expected-completion pattern","created_at":"2026-03-05T00:46:18Z"}]}
{"id":"bd-1h9","title":"Define FABRIC data types in Rust (LogEvent, WorkerInfo, BeadState)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-05T00:50:07.069561634Z","created_by":"coder","updated_at":"2026-03-05T00:55:12.163696015Z","closed_at":"2026-03-05T00:55:12.163364019Z","close_reason":"done","closed_by_session":"frankentui-needs-rust-project","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1j9","title":"E2E test: WorkerDetail shows selected worker info","description":"Create a vitest test that verifies WorkerDetail panel shows correct information for a selected worker including status, uptime, beads completed, and recent events.","status":"open","priority":1,"issue_type":"task","created_at":"2026-03-05T00:50:19.529325776Z","created_by":"coder","updated_at":"2026-03-05T00:56:22.845052039Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1j9","depends_on_id":"bd-3p3","type":"blocks","created_at":"2026-03-05T00:50:53.704886232Z","created_by":"coder","metadata":"{}","thread_id":""}]}
{"id":"bd-1j9","title":"E2E test: WorkerDetail shows selected worker info","description":"Create a vitest test that verifies WorkerDetail panel shows correct information for a selected worker including status, uptime, beads completed, and recent events.","status":"in_progress","priority":1,"issue_type":"task","assignee":"coder","created_at":"2026-03-05T00:50:19.529325776Z","created_by":"coder","updated_at":"2026-03-05T03:54:52.723334383Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1j9","depends_on_id":"bd-3p3","type":"blocks","created_at":"2026-03-05T00:50:53.704886232Z","created_by":"coder","metadata":"{}","thread_id":""}]}
{"id":"bd-1ob","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 73635s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-05T00:24:44.176888787Z","created_by":"coder","updated_at":"2026-03-05T00:31:32.239664018Z","closed_at":"2026-03-05T00:31:32.239366877Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":3,"issue_id":"bd-1ob","author":"Jed Arden","text":"EXPECTED COMPLETION: FABRIC project is 100% complete (ROADMAP.md). The 6 remaining open beads are manual TUI testing tasks (Test TUI [j/k], [/], [Tab], [E], [D], [H] keys) that require human interaction to verify. These are not implementation tasks suitable for autonomous workers. Closing as expected behavior - no work available is correct for a completed project.","created_at":"2026-03-05T00:31:26Z"}]}
{"id":"bd-1p8","title":"Add hot reload for TUI when workers.log changes","description":"Add fs.watch or chokidar to monitor ~/.needle/logs/workers.log for changes. When new lines are appended, parse them and update the TUI in real-time without requiring manual refresh.","status":"in_progress","priority":1,"issue_type":"task","assignee":"coder","created_at":"2026-03-05T00:55:19.912129631Z","created_by":"coder","updated_at":"2026-03-05T00:56:52.174731940Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1q7","title":"Implement keyboard navigation (Tab, j/k, vim bindings)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-05T00:50:09.187215795Z","created_by":"coder","updated_at":"2026-03-05T00:55:16.561136543Z","closed_at":"2026-03-05T00:55:16.560684728Z","close_reason":"done","closed_by_session":"frankentui-needs-rust-project","source_repo":".","compaction_level":0,"original_size":0}

View file

@ -0,0 +1,674 @@
/**
* E2E Test: Keyboard Navigation
*
* Verifies that keyboard shortcuts work correctly:
* - Tab switches panel focus
* - j/k scrolls (via blessed vi mode)
* - H shows heatmap view
* - D shows DAG view
* - E shows errors view
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import blessed from 'blessed';
// Mock process.exit before importing
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
// Mock blessed module with comprehensive mock elements
vi.mock('blessed', () => {
const createMockElement = () => ({
setContent: vi.fn(),
setLabel: vi.fn(),
getContent: vi.fn(() => ''),
show: vi.fn(),
hide: vi.fn(),
focus: vi.fn(),
key: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
hidden: true,
screen: {
render: vi.fn(),
destroy: vi.fn(),
append: vi.fn(),
key: vi.fn(),
focusNext: vi.fn(),
focusPrevious: vi.fn(),
},
});
const mockBoxInstance = createMockElement();
const mockLogInstance = {
...createMockElement(),
log: vi.fn(),
};
const mockScreen = {
render: vi.fn(),
destroy: vi.fn(),
append: vi.fn(),
key: vi.fn(),
focusNext: vi.fn(),
focusPrevious: vi.fn(),
};
return {
default: {
screen: vi.fn(() => mockScreen),
box: vi.fn(() => mockBoxInstance),
log: vi.fn(() => mockLogInstance),
textbox: vi.fn(() => mockBoxInstance),
list: vi.fn(() => mockBoxInstance),
},
screen: vi.fn(() => mockScreen),
box: vi.fn(() => mockBoxInstance),
log: vi.fn(() => mockLogInstance),
textbox: vi.fn(() => mockBoxInstance),
list: vi.fn(() => mockBoxInstance),
};
});
// Mock all components
vi.mock('./components/WorkerGrid.js', () => ({
WorkerGrid: class {
updateWorkers = vi.fn();
getSelected = vi.fn(() => null);
focus = vi.fn();
getElement = vi.fn(() => ({ hide: vi.fn(), show: vi.fn(), screen: { render: vi.fn() } }));
setFocusMode = vi.fn();
selectNext = vi.fn();
selectPrevious = vi.fn();
},
}));
vi.mock('./components/ActivityStream.js', () => ({
ActivityStream: class {
addEvent = vi.fn();
clearFilter = vi.fn();
setFilter = vi.fn();
togglePause = vi.fn();
focus = vi.fn();
getElement = vi.fn(() => ({ hide: vi.fn(), show: vi.fn(), screen: { render: vi.fn() }, key: vi.fn() }));
getIsPaused = vi.fn(() => false);
setFocusMode = vi.fn();
getFilter = vi.fn(() => ({}));
getEventsCount = vi.fn(() => 0);
getFilteredEventsCount = vi.fn(() => 0);
},
}));
vi.mock('./components/WorkerDetail.js', () => ({
WorkerDetail: class {
setWorker = vi.fn();
setRecentEvents = vi.fn();
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
getElement = vi.fn(() => ({ hide: vi.fn(), show: vi.fn(), screen: { render: vi.fn() } }));
},
}));
vi.mock('./components/CommandPalette.js', () => ({
CommandPalette: class {
toggle = vi.fn();
show = vi.fn();
hide = vi.fn();
isVisible = vi.fn(() => false);
addSuggestion = vi.fn();
},
}));
vi.mock('./components/FileHeatmap.js', () => ({
FileHeatmap: class {
updateData = vi.fn();
focus = vi.fn();
getElement = vi.fn(() => ({ hide: vi.fn(), show: vi.fn(), screen: { render: vi.fn() } }));
getSelected = vi.fn(() => null);
getSortMode = vi.fn(() => 'modifications');
getCollisionFilter = vi.fn(() => false);
},
}));
vi.mock('./components/DependencyDag.js', () => ({
DependencyDag: class {
refresh = vi.fn();
focus = vi.fn();
getElement = vi.fn(() => ({ hide: vi.fn(), show: vi.fn(), screen: { render: vi.fn() } }));
getGraph = vi.fn(() => null);
getStats = vi.fn(() => null);
},
}));
vi.mock('./components/SessionReplay.js', () => ({
SessionReplay: class {
loadEvents = vi.fn();
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
getState = vi.fn(() => 'ready');
getSpeed = vi.fn(() => 1);
play = vi.fn();
pause = vi.fn();
reset = vi.fn();
},
}));
vi.mock('./components/ErrorGroupPanel.js', () => ({
ErrorGroupPanel: class {
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
updateGroups = vi.fn();
},
}));
vi.mock('./components/SessionDigest.js', () => ({
SessionDigest: class {
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
setDigest = vi.fn();
},
generateSessionDigest: vi.fn(() => ({
sessionStart: Date.now(),
sessionEnd: Date.now(),
duration: 60000,
totalWorkers: 0,
totalBeadsCompleted: 0,
totalFilesModified: 0,
totalErrors: 0,
topWorkers: [],
beadTimeline: [],
fileModifications: [],
errorSummary: [],
workerSummaries: [],
})),
}));
vi.mock('./components/CollisionAlert.js', () => ({
CollisionAlert: class {
show = vi.fn();
hide = vi.fn();
updateAlerts = vi.fn();
},
}));
vi.mock('./components/GitIntegration.js', () => ({
GitIntegration: class {
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
updateGitEvents = vi.fn();
},
}));
vi.mock('./components/SemanticNarrativePanel.js', () => ({
SemanticNarrativePanel: class {
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
updateAggregated = vi.fn();
},
}));
vi.mock('./components/WorkerAnalyticsPanel.js', () => ({
WorkerAnalyticsPanel: class {
show = vi.fn();
hide = vi.fn();
focus = vi.fn();
setMetrics = vi.fn();
},
}));
// Import after mocking
import { FabricTuiApp } from './app.js';
import { InMemoryEventStore } from '../store.js';
import { LogEvent } from '../types.js';
// Helper functions
function createMockStore(): InMemoryEventStore {
return new InMemoryEventStore();
}
function createMockEvent(overrides: Partial<LogEvent> = {}): LogEvent {
return {
ts: Date.now(),
worker: 'w-test123',
level: 'info',
msg: 'Test event message',
...overrides,
};
}
function getMockScreen() {
return (blessed.screen as Mock)();
}
function getKeyHandler(mockScreen: any, keyBinding: string | string[]): (() => void) | undefined {
const keyBindings = Array.isArray(keyBinding) ? keyBinding : [keyBinding];
const keyCall = mockScreen.key.mock.calls.find(
(call: unknown[]) => {
if (!Array.isArray(call?.[0])) return false;
const callKeys = call[0] as string[];
return keyBindings.some(key => callKeys.includes(key));
}
);
return keyCall?.[1] as (() => void) | undefined;
}
describe('E2E: Keyboard Navigation', () => {
let store: InMemoryEventStore;
let app: FabricTuiApp;
let mockScreen: any;
beforeEach(() => {
vi.clearAllMocks();
mockExit.mockClear();
store = createMockStore();
app = new FabricTuiApp(store);
app.start();
mockScreen = getMockScreen();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Tab Navigation', () => {
it('should focus next element when Tab is pressed', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
expect(tabHandler).toBeDefined();
tabHandler!();
expect(mockScreen.focusNext).toHaveBeenCalled();
});
it('should focus previous element when Shift+Tab is pressed', () => {
const stabHandler = getKeyHandler(mockScreen, 'S-tab');
expect(stabHandler).toBeDefined();
stabHandler!();
expect(mockScreen.focusPrevious).toHaveBeenCalled();
});
it('should cycle through multiple tab presses', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
expect(tabHandler).toBeDefined();
// Press tab multiple times
tabHandler!();
tabHandler!();
tabHandler!();
expect(mockScreen.focusNext).toHaveBeenCalledTimes(3);
});
it('should reverse cycle with shift+tab', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
const stabHandler = getKeyHandler(mockScreen, 'S-tab');
expect(tabHandler).toBeDefined();
expect(stabHandler).toBeDefined();
// Move forward then backward
tabHandler!();
stabHandler!();
expect(mockScreen.focusNext).toHaveBeenCalledTimes(1);
expect(mockScreen.focusPrevious).toHaveBeenCalledTimes(1);
});
});
describe('j/k Scrolling', () => {
it('should enable vi mode for ActivityStream with j/k keys', () => {
// ActivityStream is created with vi: true, which enables j/k scrolling
// This is built into blessed, so we verify the component was created with vi mode
const blessedMock = blessed as unknown as { log: Mock };
// Check that blessed.log was called with vi: true
const logCalls = blessedMock.log.mock.calls;
const viEnabledCall = logCalls.find((call: any[]) => call[0]?.vi === true);
expect(viEnabledCall).toBeDefined();
expect(viEnabledCall?.[0]?.vi).toBe(true);
expect(viEnabledCall?.[0]?.keys).toBe(true);
expect(viEnabledCall?.[0]?.scrollable).toBe(true);
});
it('should create ActivityStream with scrollable options', () => {
const blessedMock = blessed as unknown as { log: Mock };
// Verify ActivityStream was created with proper scrolling options
const logCalls = blessedMock.log.mock.calls;
const scrollableCall = logCalls.find((call: any[]) => call[0]?.scrollable === true);
expect(scrollableCall).toBeDefined();
expect(scrollableCall?.[0]?.scrollable).toBe(true);
expect(scrollableCall?.[0]?.alwaysScroll).toBe(true);
});
});
describe('View Mode Navigation', () => {
it('should show heatmap view when H is pressed', () => {
const hHandler = getKeyHandler(mockScreen, 'H');
expect(hHandler).toBeDefined();
// Initially should not throw
expect(() => hHandler!()).not.toThrow();
// Screen should be rendered
expect(mockScreen.render).toHaveBeenCalled();
});
it('should show DAG view when D is pressed', () => {
const dHandler = getKeyHandler(mockScreen, 'D');
expect(dHandler).toBeDefined();
expect(() => dHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should show errors view when E is pressed', () => {
const eHandler = getKeyHandler(mockScreen, 'E');
expect(eHandler).toBeDefined();
expect(() => eHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should toggle heatmap view on/off with repeated H presses', () => {
const hHandler = getKeyHandler(mockScreen, 'H');
expect(hHandler).toBeDefined();
// First press - show heatmap
vi.clearAllMocks();
hHandler!();
expect(mockScreen.render).toHaveBeenCalled();
// Second press - hide heatmap (return to default)
vi.clearAllMocks();
hHandler!();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should toggle DAG view on/off with repeated D presses', () => {
const dHandler = getKeyHandler(mockScreen, 'D');
expect(dHandler).toBeDefined();
// First press - show DAG
vi.clearAllMocks();
dHandler!();
expect(mockScreen.render).toHaveBeenCalled();
// Second press - hide DAG (return to default)
vi.clearAllMocks();
dHandler!();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should toggle errors view on/off with repeated E presses', () => {
const eHandler = getKeyHandler(mockScreen, 'E');
expect(eHandler).toBeDefined();
// First press - show errors
vi.clearAllMocks();
eHandler!();
expect(mockScreen.render).toHaveBeenCalled();
// Second press - hide errors (return to default)
vi.clearAllMocks();
eHandler!();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should switch between different views', () => {
const hHandler = getKeyHandler(mockScreen, 'H');
const dHandler = getKeyHandler(mockScreen, 'D');
const eHandler = getKeyHandler(mockScreen, 'E');
expect(hHandler).toBeDefined();
expect(dHandler).toBeDefined();
expect(eHandler).toBeDefined();
// Switch between multiple views
expect(() => {
hHandler!(); // Show heatmap
dHandler!(); // Switch to DAG
eHandler!(); // Switch to errors
}).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should return to default view with Escape key', () => {
const hHandler = getKeyHandler(mockScreen, 'H');
const escHandler = getKeyHandler(mockScreen, 'escape');
expect(hHandler).toBeDefined();
expect(escHandler).toBeDefined();
// Show heatmap
hHandler!();
// Press escape to return to default
vi.clearAllMocks();
escHandler!();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should do nothing when Escape pressed in default view', () => {
const escHandler = getKeyHandler(mockScreen, 'escape');
expect(escHandler).toBeDefined();
vi.clearAllMocks();
// Press escape in default view (should do nothing)
escHandler!();
// render might not be called since view didn't change
// Just verify it doesn't throw
expect(() => escHandler!()).not.toThrow();
});
});
describe('Complete Keyboard Navigation Workflow', () => {
it('should handle realistic navigation sequence', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
const hHandler = getKeyHandler(mockScreen, 'H');
const dHandler = getKeyHandler(mockScreen, 'D');
const eHandler = getKeyHandler(mockScreen, 'E');
const escHandler = getKeyHandler(mockScreen, 'escape');
// Realistic user workflow
expect(() => {
// User tabs through panels
tabHandler!();
tabHandler!();
// User opens heatmap
hHandler!();
// User tabs in heatmap view
tabHandler!();
// User switches to DAG view
dHandler!();
// User switches to errors view
eHandler!();
// User returns to default
escHandler!();
// User continues tabbing
tabHandler!();
}).not.toThrow();
// Verify navigation methods were called
expect(mockScreen.focusNext).toHaveBeenCalled();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should handle rapid keyboard input', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
const hHandler = getKeyHandler(mockScreen, 'H');
const dHandler = getKeyHandler(mockScreen, 'D');
const eHandler = getKeyHandler(mockScreen, 'E');
// Rapid key presses
expect(() => {
for (let i = 0; i < 10; i++) {
tabHandler!();
hHandler!();
dHandler!();
eHandler!();
}
}).not.toThrow();
});
it('should maintain state across view switches', () => {
// Add events to store
store.add(createMockEvent({ msg: 'Event 1' }));
store.add(createMockEvent({ msg: 'Event 2' }));
const hHandler = getKeyHandler(mockScreen, 'H');
const escHandler = getKeyHandler(mockScreen, 'escape');
// Switch views
hHandler!();
escHandler!();
// Events should still be in store
expect(store.query().length).toBe(2);
});
it('should work with events being added during navigation', () => {
const tabHandler = getKeyHandler(mockScreen, 'tab');
const hHandler = getKeyHandler(mockScreen, 'H');
// Navigate while adding events
expect(() => {
tabHandler!();
app.addEvent(createMockEvent({ msg: 'Event during tab' }));
hHandler!();
app.addEvent(createMockEvent({ msg: 'Event during heatmap' }));
tabHandler!();
app.addEvent(createMockEvent({ msg: 'Event during second tab' }));
}).not.toThrow();
// All events should be in store
expect(store.query().length).toBe(3);
});
});
describe('Additional Navigation Keys', () => {
it('should handle refresh key (r)', () => {
const rHandler = getKeyHandler(mockScreen, 'r');
expect(rHandler).toBeDefined();
vi.clearAllMocks();
rHandler!();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should toggle help with ?', () => {
const helpHandler = getKeyHandler(mockScreen, '?');
expect(helpHandler).toBeDefined();
expect(() => {
helpHandler!();
helpHandler!();
}).not.toThrow();
});
it('should open command palette with Ctrl+K', () => {
const ckHandler = getKeyHandler(mockScreen, 'C-k');
expect(ckHandler).toBeDefined();
expect(() => ckHandler!()).not.toThrow();
});
it('should handle collision view (C key)', () => {
const cHandler = getKeyHandler(mockScreen, 'C');
expect(cHandler).toBeDefined();
expect(() => cHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should handle git integration view (I key)', () => {
const iHandler = getKeyHandler(mockScreen, 'I');
expect(iHandler).toBeDefined();
expect(() => iHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should handle narrative view (N key)', () => {
const nHandler = getKeyHandler(mockScreen, 'N');
expect(nHandler).toBeDefined();
expect(() => nHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
it('should handle analytics view (A key)', () => {
const aHandler = getKeyHandler(mockScreen, 'A');
expect(aHandler).toBeDefined();
expect(() => aHandler!()).not.toThrow();
expect(mockScreen.render).toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('should handle keyboard input with empty store', () => {
const emptyStore = createMockStore();
const emptyApp = new FabricTuiApp(emptyStore);
emptyApp.start();
const emptyScreen = getMockScreen();
const tabHandler = getKeyHandler(emptyScreen, 'tab');
const hHandler = getKeyHandler(emptyScreen, 'H');
expect(() => {
tabHandler!();
hHandler!();
}).not.toThrow();
});
it('should handle keyboard input before app start', () => {
const newStore = createMockStore();
const newApp = new FabricTuiApp(newStore);
// Try to get handlers before start - should still be bound
expect(() => newApp.render()).not.toThrow();
});
it('should handle invalid key sequences gracefully', () => {
// All handlers should not throw even with unusual sequences
const handlers = [
getKeyHandler(mockScreen, 'tab'),
getKeyHandler(mockScreen, 'H'),
getKeyHandler(mockScreen, 'D'),
getKeyHandler(mockScreen, 'E'),
getKeyHandler(mockScreen, 'escape'),
].filter(h => h !== undefined) as (() => void)[];
expect(() => {
// Random sequence of keypresses
handlers.forEach(h => h());
handlers.reverse().forEach(h => h());
handlers.forEach(h => h());
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,523 @@
/**
* Tests for WorkerDetail component
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import WorkerDetail from '../src/components/WorkerDetail';
import { WorkerInfo, LogEvent } from '../src/types';
describe('WorkerDetail', () => {
const createMockWorker = (overrides: Partial<WorkerInfo> = {}): WorkerInfo => ({
id: 'worker-alpha',
lastSeen: new Date().toISOString(),
eventCount: 10,
status: 'active',
recentEvents: [],
...overrides,
});
const createMockEvent = (overrides: Partial<LogEvent> = {}): LogEvent => ({
timestamp: '2026-03-05T12:00:00.000Z',
level: 'info',
worker: 'worker-alpha',
message: 'Test event',
raw: '{"ts":123,"worker":"worker-alpha","level":"info","msg":"Test event"}',
...overrides,
});
const mockOnClose = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
});
afterEach(() => {
cleanup();
});
describe('rendering', () => {
it('should render worker detail panel', () => {
const worker = createMockWorker({ id: 'worker-alpha' });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('worker-alpha')).toBeInTheDocument();
});
it('should render close button', () => {
const worker = createMockWorker();
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
const closeButton = screen.getByTitle('Close details');
expect(closeButton).toBeInTheDocument();
expect(closeButton.textContent).toBe('✕');
});
it('should call onClose when close button is clicked', () => {
const worker = createMockWorker();
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
fireEvent.click(screen.getByTitle('Close details'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('worker status display', () => {
it('should display active status with correct icon', () => {
const worker = createMockWorker({ status: 'active' });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(screen.getByText('active')).toBeInTheDocument();
expect(container.querySelector('.worker-status.active')).toBeInTheDocument();
expect(container.querySelector('.worker-status-icon.active')).toBeInTheDocument();
});
it('should display idle status with correct icon', () => {
const worker = createMockWorker({ status: 'idle' });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(screen.getByText('idle')).toBeInTheDocument();
expect(container.querySelector('.worker-status.idle')).toBeInTheDocument();
expect(container.querySelector('.worker-status-icon.idle')).toBeInTheDocument();
});
it('should display error status with correct icon', () => {
const worker = createMockWorker({ status: 'error' });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(screen.getByText('error')).toBeInTheDocument();
expect(container.querySelector('.worker-status.error')).toBeInTheDocument();
expect(container.querySelector('.worker-status-icon.error')).toBeInTheDocument();
});
});
describe('event count display', () => {
it('should display event count', () => {
const worker = createMockWorker({ eventCount: 42 });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('42')).toBeInTheDocument();
});
it('should display zero event count', () => {
const worker = createMockWorker({ eventCount: 0 });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('should display large event count', () => {
const worker = createMockWorker({ eventCount: 9999 });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('9999')).toBeInTheDocument();
});
});
describe('current tool display', () => {
it('should display current tool when present', () => {
const worker = createMockWorker({ currentTool: 'Read' });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
// Current tool appears in the status section
const toolNameElement = container.querySelector('.detail-value.tool-name');
expect(toolNameElement).toBeInTheDocument();
expect(toolNameElement?.textContent).toBe('Read');
});
it('should display dash when no current tool', () => {
const worker = createMockWorker({ currentTool: undefined });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
});
describe('last seen formatting', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should display seconds ago for recent events', () => {
const worker = createMockWorker({
lastSeen: new Date(Date.now() - 30000).toISOString(), // 30 seconds ago
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText(/30s ago/)).toBeInTheDocument();
});
it('should display minutes ago for events within an hour', () => {
const worker = createMockWorker({
lastSeen: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 minutes ago
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText(/5m ago/)).toBeInTheDocument();
});
it('should display hours and minutes for older events', () => {
const worker = createMockWorker({
lastSeen: new Date(Date.now() - 2 * 60 * 60 * 1000 - 30 * 60 * 1000).toISOString(), // 2h 30m ago
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText(/2h 30m ago/)).toBeInTheDocument();
});
});
describe('recent events display', () => {
it('should display "No events recorded" when no events', () => {
const worker = createMockWorker({ recentEvents: [] });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('No events recorded')).toBeInTheDocument();
});
it('should display event count in section header', () => {
const worker = createMockWorker({
recentEvents: [
createMockEvent({ message: 'Event 1' }),
createMockEvent({ message: 'Event 2' }),
createMockEvent({ message: 'Event 3' }),
],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('Recent Events (3)')).toBeInTheDocument();
});
it('should display event messages', () => {
const worker = createMockWorker({
recentEvents: [
createMockEvent({ message: 'First event' }),
createMockEvent({ message: 'Second event' }),
],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('First event')).toBeInTheDocument();
expect(screen.getByText('Second event')).toBeInTheDocument();
});
it('should display event levels', () => {
const worker = createMockWorker({
recentEvents: [
createMockEvent({ message: 'Info event', level: 'info' }),
createMockEvent({ message: 'Warn event', level: 'warn' }),
createMockEvent({ message: 'Error event', level: 'error' }),
],
});
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(screen.getByText('INF')).toBeInTheDocument();
expect(screen.getByText('WAR')).toBeInTheDocument();
expect(screen.getByText('ERR')).toBeInTheDocument();
expect(container.querySelector('.detail-event-level.info')).toBeInTheDocument();
expect(container.querySelector('.detail-event-level.warn')).toBeInTheDocument();
expect(container.querySelector('.detail-event-level.error')).toBeInTheDocument();
});
it('should truncate long messages', () => {
const longMessage = 'A'.repeat(100);
const worker = createMockWorker({
recentEvents: [createMockEvent({ message: longMessage })],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
// Message should be truncated to 35 chars + '...'
expect(screen.getByText(/A{35}\.\.\./)).toBeInTheDocument();
});
it('should not truncate short messages', () => {
const worker = createMockWorker({
recentEvents: [createMockEvent({ message: 'Short message' })],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('Short message')).toBeInTheDocument();
});
it('should display last 10 events when more than 10', () => {
const events = Array.from({ length: 20 }, (_, i) =>
createMockEvent({ message: `Event ${i}` })
);
const worker = createMockWorker({ recentEvents: events });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
// Should show count of all events
expect(screen.getByText('Recent Events (20)')).toBeInTheDocument();
// But only render last 10
const eventItems = container.querySelectorAll('.detail-event-item');
expect(eventItems).toHaveLength(10);
// Should show Event 10-19 (last 10)
expect(screen.getByText('Event 19')).toBeInTheDocument();
expect(screen.queryByText('Event 0')).not.toBeInTheDocument();
expect(screen.queryByText('Event 9')).not.toBeInTheDocument();
});
it('should use allWorkerEvents when provided', () => {
const worker = createMockWorker({
recentEvents: [createMockEvent({ message: 'Worker event' })],
});
const allEvents = [
createMockEvent({ message: 'All event 1' }),
createMockEvent({ message: 'All event 2' }),
];
render(
<WorkerDetail
worker={worker}
onClose={mockOnClose}
allWorkerEvents={allEvents}
/>
);
expect(screen.getByText('All event 1')).toBeInTheDocument();
expect(screen.getByText('All event 2')).toBeInTheDocument();
expect(screen.queryByText('Worker event')).not.toBeInTheDocument();
});
});
describe('collision alert display', () => {
it('should display collision alert when worker has collision', () => {
const worker = createMockWorker({ hasCollision: true });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('File collision detected!')).toBeInTheDocument();
expect(screen.getByText('⚠️')).toBeInTheDocument();
});
it('should not display collision alert when worker has no collision', () => {
const worker = createMockWorker({ hasCollision: false });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.queryByText('File collision detected!')).not.toBeInTheDocument();
});
it('should display collision files when provided', () => {
const worker = createMockWorker({
hasCollision: true,
activeFiles: ['/src/file1.ts', '/src/file2.ts', '/src/file3.ts'],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('file1.ts')).toBeInTheDocument();
expect(screen.getByText('file2.ts')).toBeInTheDocument();
expect(screen.getByText('file3.ts')).toBeInTheDocument();
});
it('should display only first 3 collision files', () => {
const worker = createMockWorker({
hasCollision: true,
activeFiles: ['/src/file1.ts', '/src/file2.ts', '/src/file3.ts', '/src/file4.ts', '/src/file5.ts'],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('file1.ts')).toBeInTheDocument();
expect(screen.getByText('file2.ts')).toBeInTheDocument();
expect(screen.getByText('file3.ts')).toBeInTheDocument();
expect(screen.getByText('+2 more')).toBeInTheDocument();
expect(screen.queryByText('file4.ts')).not.toBeInTheDocument();
expect(screen.queryByText('file5.ts')).not.toBeInTheDocument();
});
it('should not display "more" text when exactly 3 files', () => {
const worker = createMockWorker({
hasCollision: true,
activeFiles: ['/src/file1.ts', '/src/file2.ts', '/src/file3.ts'],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.queryByText(/\+\d+ more/)).not.toBeInTheDocument();
});
});
describe('current activity section', () => {
it('should display current activity section when tool is present', () => {
const worker = createMockWorker({ currentTool: 'Edit' });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('Current Activity')).toBeInTheDocument();
});
it('should not display current activity section when no tool', () => {
const worker = createMockWorker({ currentTool: undefined });
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.queryByText('Current Activity')).not.toBeInTheDocument();
});
});
describe('CSS classes', () => {
it('should apply worker-detail class to container', () => {
const worker = createMockWorker();
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(container.querySelector('.worker-detail')).toBeInTheDocument();
});
it('should apply detail-section classes', () => {
const worker = createMockWorker();
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(container.querySelectorAll('.detail-section').length).toBeGreaterThan(0);
});
it('should apply collision-alert class when collision present', () => {
const worker = createMockWorker({ hasCollision: true });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
expect(container.querySelector('.collision-alert')).toBeInTheDocument();
});
});
describe('accessibility', () => {
it('should have title attribute on close button', () => {
const worker = createMockWorker();
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
const closeButton = screen.getByRole('button');
expect(closeButton).toHaveAttribute('title', 'Close details');
});
it('should have title attribute on file names showing full path', () => {
const worker = createMockWorker({
hasCollision: true,
activeFiles: ['/very/long/path/to/file.ts'],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
const fileElement = screen.getByText('file.ts');
expect(fileElement).toHaveAttribute('title', '/very/long/path/to/file.ts');
});
it('should have title attribute on lastSeen showing full timestamp', () => {
const timestamp = '2026-03-05T12:34:56.789Z';
const worker = createMockWorker({ lastSeen: timestamp });
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
const lastSeenElement = container.querySelector('.detail-row .detail-value[title]');
expect(lastSeenElement).toHaveAttribute('title', timestamp);
});
it('should have title attribute on truncated event messages', () => {
const longMessage = 'This is a very long message that will be truncated in the display';
const worker = createMockWorker({
recentEvents: [createMockEvent({ message: longMessage })],
});
const { container } = render(
<WorkerDetail worker={worker} onClose={mockOnClose} />
);
const messageElement = container.querySelector('.detail-event-msg[title]');
expect(messageElement).toHaveAttribute('title', longMessage);
});
});
describe('edge cases', () => {
it('should handle worker with all undefined optional fields', () => {
const worker: WorkerInfo = {
id: 'minimal-worker',
lastSeen: new Date().toISOString(),
eventCount: 0,
status: 'idle',
recentEvents: [],
currentTool: undefined,
hasCollision: undefined,
activeFiles: undefined,
};
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('minimal-worker')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('idle')).toBeInTheDocument();
});
it('should handle empty active files array with collision flag', () => {
const worker = createMockWorker({
hasCollision: true,
activeFiles: [],
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText('File collision detected!')).toBeInTheDocument();
expect(screen.queryByText(/\.ts/)).not.toBeInTheDocument();
});
it('should handle very old lastSeen timestamp', () => {
const worker = createMockWorker({
lastSeen: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 24 hours ago
});
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
expect(screen.getByText(/24h 0m ago/)).toBeInTheDocument();
});
});
});