diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bda1151..5111b83 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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} diff --git a/src/tui/keyboardNavigation.e2e.test.ts b/src/tui/keyboardNavigation.e2e.test.ts new file mode 100644 index 0000000..55d2d24 --- /dev/null +++ b/src/tui/keyboardNavigation.e2e.test.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/src/web/frontend/test/WorkerDetail.test.tsx b/src/web/frontend/test/WorkerDetail.test.tsx new file mode 100644 index 0000000..b25ef1f --- /dev/null +++ b/src/web/frontend/test/WorkerDetail.test.tsx @@ -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 => ({ + id: 'worker-alpha', + lastSeen: new Date().toISOString(), + eventCount: 10, + status: 'active', + recentEvents: [], + ...overrides, + }); + + const createMockEvent = (overrides: Partial = {}): 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(); + + expect(screen.getByText('worker-alpha')).toBeInTheDocument(); + }); + + it('should render close button', () => { + const worker = createMockWorker(); + + render(); + + 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(); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('should display zero event count', () => { + const worker = createMockWorker({ eventCount: 0 }); + + render(); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should display large event count', () => { + const worker = createMockWorker({ eventCount: 9999 }); + + render(); + + expect(screen.getByText('9999')).toBeInTheDocument(); + }); + }); + + describe('current tool display', () => { + it('should display current tool when present', () => { + const worker = createMockWorker({ currentTool: 'Read' }); + + const { container } = render( + + ); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + + ); + + 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(); + + // 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(); + + 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( + + ); + + // 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( + + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('Current Activity')).toBeInTheDocument(); + }); + + it('should not display current activity section when no tool', () => { + const worker = createMockWorker({ currentTool: undefined }); + + render(); + + expect(screen.queryByText('Current Activity')).not.toBeInTheDocument(); + }); + }); + + describe('CSS classes', () => { + it('should apply worker-detail class to container', () => { + const worker = createMockWorker(); + const { container } = render( + + ); + + expect(container.querySelector('.worker-detail')).toBeInTheDocument(); + }); + + it('should apply detail-section classes', () => { + const worker = createMockWorker(); + const { container } = render( + + ); + + 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( + + ); + + expect(container.querySelector('.collision-alert')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have title attribute on close button', () => { + const worker = createMockWorker(); + + render(); + + 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(); + + 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( + + ); + + 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( + + ); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText(/24h 0m ago/)).toBeInTheDocument(); + }); + }); +});