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:
parent
e28dfb6744
commit
41052cb0dd
3 changed files with 1198 additions and 1 deletions
|
|
@ -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}
|
||||
|
|
|
|||
674
src/tui/keyboardNavigation.e2e.test.ts
Normal file
674
src/tui/keyboardNavigation.e2e.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
523
src/web/frontend/test/WorkerDetail.test.tsx
Normal file
523
src/web/frontend/test/WorkerDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue