Implemented comprehensive ErrorGroupPanel TUI component with: - Display of grouped errors with count, first/last occurrence, affected workers - Severity-based grouping (critical, high, medium, low) - Category icons for error types (network, permission, validation, etc.) - Expandable stack traces with keyboard navigation - Active/inactive status indicators - Integration with ErrorGroupManager for data - Added to main app with 'E' keyboard shortcut - 56 unit tests covering all functionality Components added: - src/tui/components/ErrorGroupPanel.ts - src/tui/components/ErrorGroupPanel.test.ts Integration: - Updated src/tui/components/index.ts to export new component - Updated src/tui/app.ts to add errors view mode with keyboard navigation - Added 'E' key shortcut to toggle error groups view Tests: All 802 tests pass (56 new ErrorGroupPanel tests) Co-Authored-By: Claude Worker <noreply@anthropic.com>
861 lines
25 KiB
TypeScript
861 lines
25 KiB
TypeScript
/**
|
|
* Tests for ErrorGroupPanel Component
|
|
*
|
|
* Tests error group rendering, navigation, expansion, and severity display.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import * as blessed from 'blessed';
|
|
|
|
// Mock the blessed module before importing ErrorGroupPanel
|
|
vi.mock('blessed', () => {
|
|
// Create mock instances that will be shared
|
|
const createMockBox = () => ({
|
|
setContent: vi.fn(),
|
|
focus: vi.fn(),
|
|
key: vi.fn(),
|
|
show: vi.fn(),
|
|
hide: vi.fn(),
|
|
visible: true,
|
|
screen: {
|
|
render: vi.fn(),
|
|
},
|
|
scroll: vi.fn(),
|
|
});
|
|
|
|
const mockBoxInstance = createMockBox();
|
|
const mockDetailBoxInstance = createMockBox();
|
|
|
|
const mockListInstance = {
|
|
setContent: vi.fn(),
|
|
key: vi.fn(),
|
|
focus: vi.fn(),
|
|
};
|
|
|
|
const mockBox = vi.fn((options) => {
|
|
// Return detail box instance for detail view
|
|
if (options.label === ' Details ') {
|
|
return mockDetailBoxInstance;
|
|
}
|
|
return mockBoxInstance;
|
|
});
|
|
|
|
const mockList = vi.fn(() => mockListInstance);
|
|
|
|
// Store instances for test access
|
|
mockBox._mockBoxInstance = mockBoxInstance;
|
|
mockBox._mockDetailBoxInstance = mockDetailBoxInstance;
|
|
mockList._mockListInstance = mockListInstance;
|
|
|
|
return {
|
|
default: {
|
|
box: mockBox,
|
|
list: mockList,
|
|
},
|
|
box: mockBox,
|
|
list: mockList,
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
import { ErrorGroupPanel } from './ErrorGroupPanel.js';
|
|
import { ErrorGroup, ErrorFingerprint, LogEvent } from '../../types.js';
|
|
|
|
// Helper to create mock error fingerprint
|
|
function createMockFingerprint(overrides: Partial<ErrorFingerprint> = {}): ErrorFingerprint {
|
|
return {
|
|
signature: 'ECONNREFUSED *:*',
|
|
category: 'network',
|
|
sampleMessage: 'Error: connect ECONNREFUSED 127.0.0.1:5432',
|
|
hash: '1a2b3c4d',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Helper to create mock error event
|
|
function createMockEvent(overrides: Partial<LogEvent> = {}): LogEvent {
|
|
return {
|
|
ts: Date.now(),
|
|
worker: 'w-alice',
|
|
level: 'error',
|
|
msg: 'Error occurred',
|
|
error: 'Error: connect ECONNREFUSED 127.0.0.1:5432',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Helper to create mock error group
|
|
function createMockGroup(overrides: Partial<ErrorGroup> = {}): ErrorGroup {
|
|
return {
|
|
id: 'eg-123',
|
|
fingerprint: createMockFingerprint(),
|
|
events: [createMockEvent()],
|
|
firstSeen: Date.now() - 60000, // 1 minute ago
|
|
lastSeen: Date.now() - 10000, // 10 seconds ago
|
|
count: 1,
|
|
affectedWorkers: ['w-alice'],
|
|
isActive: true,
|
|
severity: 'medium',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Helper to create mock screen
|
|
function createMockScreen() {
|
|
return {
|
|
render: vi.fn(),
|
|
append: vi.fn(),
|
|
key: vi.fn(),
|
|
destroy: vi.fn(),
|
|
} as unknown as blessed.Widgets.Screen;
|
|
}
|
|
|
|
describe('ErrorGroupPanel', () => {
|
|
let errorGroupPanel: ErrorGroupPanel;
|
|
let mockScreen: blessed.Widgets.Screen;
|
|
let mockBoxInstance: any;
|
|
let mockListInstance: any;
|
|
let mockDetailBoxInstance: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockScreen = createMockScreen();
|
|
|
|
// Get the mock instances from the mocks
|
|
const blessedMock = blessed as unknown as {
|
|
box: vi.Mock & { _mockBoxInstance: any; _mockDetailBoxInstance: any };
|
|
list: vi.Mock & { _mockListInstance: any };
|
|
};
|
|
|
|
mockBoxInstance = blessedMock.box._mockBoxInstance;
|
|
mockDetailBoxInstance = blessedMock.box._mockDetailBoxInstance;
|
|
mockListInstance = blessedMock.list._mockListInstance;
|
|
|
|
errorGroupPanel = new ErrorGroupPanel({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
height: 20,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create a blessed box with correct options', () => {
|
|
const blessedMock = blessed as unknown as { box: vi.Mock };
|
|
expect(blessedMock.box).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
height: 20,
|
|
label: ' Error Groups ',
|
|
scrollable: true,
|
|
alwaysScroll: true,
|
|
keys: true,
|
|
vi: true,
|
|
mouse: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create a list inside the box', () => {
|
|
const blessedMock = blessed as unknown as { list: vi.Mock };
|
|
expect(blessedMock.list).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
parent: mockBoxInstance,
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '50%',
|
|
keys: true,
|
|
vi: true,
|
|
mouse: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create a detail box for expanded view', () => {
|
|
const blessedMock = blessed as unknown as { box: vi.Mock };
|
|
expect(blessedMock.box).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
label: ' Details ',
|
|
top: '50%',
|
|
scrollable: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should bind key handlers on construction', () => {
|
|
// List key bindings should be registered
|
|
expect(mockListInstance.key).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should register onSelect callback', () => {
|
|
const onSelect = vi.fn();
|
|
const panel = new ErrorGroupPanel({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
height: 20,
|
|
onSelect,
|
|
});
|
|
|
|
const groups = [createMockGroup()];
|
|
panel.updateGroups(groups);
|
|
panel.toggleExpanded();
|
|
|
|
expect(onSelect).toHaveBeenCalledWith('eg-123');
|
|
});
|
|
});
|
|
|
|
describe('updateGroups', () => {
|
|
it('should update groups list and render', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1', severity: 'medium' }),
|
|
createMockGroup({ id: 'eg-2', severity: 'critical' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
expect(mockListInstance.setContent).toHaveBeenCalled();
|
|
expect(mockBoxInstance.screen.render).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show "No errors detected" when empty', () => {
|
|
errorGroupPanel.updateGroups([]);
|
|
|
|
expect(mockListInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('No errors detected')
|
|
);
|
|
});
|
|
|
|
it('should show group count in header', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
createMockGroup({ id: 'eg-3' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
expect(mockListInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('Groups: 3')
|
|
);
|
|
});
|
|
|
|
it('should reset selected index if out of bounds', () => {
|
|
// First set some groups
|
|
errorGroupPanel.updateGroups([
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
]);
|
|
|
|
// Update to fewer groups
|
|
errorGroupPanel.updateGroups([createMockGroup({ id: 'eg-1' })]);
|
|
|
|
// Should not throw and selection should be valid
|
|
const selected = errorGroupPanel.getSelected();
|
|
expect(selected).toBeDefined();
|
|
expect(selected?.id).toBe('eg-1');
|
|
});
|
|
});
|
|
|
|
describe('selectNext', () => {
|
|
it('should move to next group', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
createMockGroup({ id: 'eg-3' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
// Initially selected is first group
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-1');
|
|
|
|
errorGroupPanel.selectNext();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-2');
|
|
});
|
|
|
|
it('should wrap to first group when at end', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
// Move to last
|
|
errorGroupPanel.selectNext();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-2');
|
|
|
|
// Wrap to first
|
|
errorGroupPanel.selectNext();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-1');
|
|
});
|
|
|
|
it('should do nothing when no groups', () => {
|
|
errorGroupPanel.updateGroups([]);
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.selectNext()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('selectPrevious', () => {
|
|
it('should move to previous group', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
createMockGroup({ id: 'eg-3' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
// Move to second
|
|
errorGroupPanel.selectNext();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-2');
|
|
|
|
// Move back to first
|
|
errorGroupPanel.selectPrevious();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-1');
|
|
});
|
|
|
|
it('should wrap to last group when at beginning', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-1' }),
|
|
createMockGroup({ id: 'eg-2' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
// At first, wrap to last
|
|
errorGroupPanel.selectPrevious();
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-2');
|
|
});
|
|
|
|
it('should do nothing when no groups', () => {
|
|
errorGroupPanel.updateGroups([]);
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.selectPrevious()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('toggleExpanded', () => {
|
|
it('should expand the selected group', () => {
|
|
const groups = [createMockGroup({ id: 'eg-xyz' })];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
errorGroupPanel.toggleExpanded();
|
|
|
|
// Detail box should show details
|
|
expect(mockDetailBoxInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('Error Group: eg-xyz')
|
|
);
|
|
});
|
|
|
|
it('should collapse when toggling expanded group', () => {
|
|
const groups = [createMockGroup({ id: 'eg-xyz' })];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
// Expand
|
|
errorGroupPanel.toggleExpanded();
|
|
expect(mockDetailBoxInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('Error Group: eg-xyz')
|
|
);
|
|
|
|
// Collapse
|
|
errorGroupPanel.toggleExpanded();
|
|
expect(mockDetailBoxInstance.setContent).toHaveBeenCalledWith(
|
|
expect.stringContaining('Select an error group and press Enter to view details')
|
|
);
|
|
});
|
|
|
|
it('should call onSelect callback when expanding', () => {
|
|
const onSelect = vi.fn();
|
|
const panel = new ErrorGroupPanel({
|
|
parent: mockScreen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '50%',
|
|
height: 20,
|
|
onSelect,
|
|
});
|
|
|
|
const groups = [createMockGroup({ id: 'eg-abc' })];
|
|
panel.updateGroups(groups);
|
|
panel.toggleExpanded();
|
|
|
|
expect(onSelect).toHaveBeenCalledWith('eg-abc');
|
|
});
|
|
|
|
it('should do nothing when no groups', () => {
|
|
errorGroupPanel.updateGroups([]);
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.toggleExpanded()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getActiveErrorCount', () => {
|
|
it('should return count of active error groups', () => {
|
|
const groups = [
|
|
createMockGroup({ isActive: true }),
|
|
createMockGroup({ isActive: true }),
|
|
createMockGroup({ isActive: false }),
|
|
createMockGroup({ isActive: true }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
expect(errorGroupPanel.getActiveErrorCount()).toBe(3);
|
|
});
|
|
|
|
it('should return 0 when all inactive', () => {
|
|
const groups = [
|
|
createMockGroup({ isActive: false }),
|
|
createMockGroup({ isActive: false }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
expect(errorGroupPanel.getActiveErrorCount()).toBe(0);
|
|
});
|
|
|
|
it('should return 0 when no groups', () => {
|
|
errorGroupPanel.updateGroups([]);
|
|
expect(errorGroupPanel.getActiveErrorCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('show and hide', () => {
|
|
it('should show the panel', () => {
|
|
errorGroupPanel.show();
|
|
|
|
expect(mockBoxInstance.show).toHaveBeenCalled();
|
|
expect(mockListInstance.focus).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should hide the panel', () => {
|
|
errorGroupPanel.hide();
|
|
|
|
expect(mockBoxInstance.hide).toHaveBeenCalled();
|
|
expect(mockBoxInstance.screen.render).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should report visibility correctly', () => {
|
|
mockBoxInstance.visible = true;
|
|
expect(errorGroupPanel.isVisible()).toBe(true);
|
|
|
|
mockBoxInstance.visible = false;
|
|
expect(errorGroupPanel.isVisible()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('focus', () => {
|
|
it('should focus the list element', () => {
|
|
errorGroupPanel.focus();
|
|
expect(mockListInstance.focus).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getElement', () => {
|
|
it('should return the box element', () => {
|
|
const element = errorGroupPanel.getElement();
|
|
expect(element).toBe(mockBoxInstance);
|
|
});
|
|
});
|
|
|
|
describe('severity display', () => {
|
|
it('should display critical severity with correct icon and color', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'critical' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('!!!'); // Critical icon
|
|
expect(content).toContain('red'); // Critical color
|
|
expect(content).toContain('CRITICAL');
|
|
});
|
|
|
|
it('should display high severity with correct icon and color', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'high' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('!!'); // High icon
|
|
expect(content).toContain('red'); // High color
|
|
});
|
|
|
|
it('should display medium severity with correct icon and color', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'medium' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('!'); // Medium icon
|
|
expect(content).toContain('yellow'); // Medium color
|
|
});
|
|
|
|
it('should display low severity with correct icon and color', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'low' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('i'); // Low icon
|
|
expect(content).toContain('blue'); // Low color
|
|
});
|
|
});
|
|
|
|
describe('category display', () => {
|
|
it('should display network category icon', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
fingerprint: createMockFingerprint({ category: 'network' }),
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('⚡'); // Network icon
|
|
});
|
|
|
|
it('should display permission category icon', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
fingerprint: createMockFingerprint({ category: 'permission' }),
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('🔒'); // Permission icon
|
|
});
|
|
|
|
it('should display validation category icon', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
fingerprint: createMockFingerprint({ category: 'validation' }),
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('✗'); // Validation icon
|
|
});
|
|
});
|
|
|
|
describe('error group grouping by severity', () => {
|
|
it('should group errors by severity level', () => {
|
|
const groups = [
|
|
createMockGroup({ id: 'eg-critical-1', severity: 'critical' }),
|
|
createMockGroup({ id: 'eg-high-1', severity: 'high' }),
|
|
createMockGroup({ id: 'eg-medium-1', severity: 'medium' }),
|
|
createMockGroup({ id: 'eg-low-1', severity: 'low' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('CRITICAL (1)');
|
|
expect(content).toContain('HIGH (1)');
|
|
expect(content).toContain('MEDIUM (1)');
|
|
expect(content).toContain('LOW (1)');
|
|
});
|
|
|
|
it('should show multiple critical errors in section', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'critical' }),
|
|
createMockGroup({ severity: 'critical' }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('CRITICAL (2)');
|
|
});
|
|
});
|
|
|
|
describe('active status display', () => {
|
|
it('should show active marker for active groups', () => {
|
|
const groups = [
|
|
createMockGroup({ isActive: true }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('●'); // Active marker
|
|
});
|
|
|
|
it('should show inactive marker for inactive groups', () => {
|
|
const groups = [
|
|
createMockGroup({ isActive: false }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('○'); // Inactive marker
|
|
});
|
|
|
|
it('should show active count in header', () => {
|
|
const groups = [
|
|
createMockGroup({ isActive: true }),
|
|
createMockGroup({ isActive: false }),
|
|
createMockGroup({ isActive: true }),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('Active: 2');
|
|
});
|
|
});
|
|
|
|
describe('worker display', () => {
|
|
it('should show worker names when 2 or fewer workers', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
affectedWorkers: ['w-alice', 'w-bob'],
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('w-alice,w-bob');
|
|
});
|
|
|
|
it('should show worker count when more than 2 workers', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
affectedWorkers: ['w-alice', 'w-bob', 'w-charlie'],
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('3w');
|
|
});
|
|
});
|
|
|
|
describe('expanded group details', () => {
|
|
it('should show group details when expanded', () => {
|
|
const group = createMockGroup({
|
|
id: 'eg-test',
|
|
fingerprint: createMockFingerprint({
|
|
signature: 'Test signature',
|
|
sampleMessage: 'Test sample message',
|
|
}),
|
|
count: 5,
|
|
affectedWorkers: ['w-alice', 'w-bob'],
|
|
});
|
|
|
|
errorGroupPanel.updateGroups([group]);
|
|
vi.clearAllMocks(); // Clear mocks before toggling to get only the expanded content
|
|
errorGroupPanel.toggleExpanded();
|
|
|
|
const content = mockDetailBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('Error Group: eg-test');
|
|
expect(content).toContain('Test signature');
|
|
expect(content).toContain('Test sample message');
|
|
expect(content).toContain('5 occurrences');
|
|
expect(content).toContain('w-alice, w-bob');
|
|
});
|
|
|
|
it('should show recent events in details', () => {
|
|
const events = [
|
|
createMockEvent({ ts: Date.now() - 1000, worker: 'w-alice' }),
|
|
createMockEvent({ ts: Date.now() - 2000, worker: 'w-bob' }),
|
|
];
|
|
|
|
const group = createMockGroup({ events });
|
|
|
|
errorGroupPanel.updateGroups([group]);
|
|
vi.clearAllMocks(); // Clear mocks before toggling
|
|
errorGroupPanel.toggleExpanded();
|
|
|
|
const content = mockDetailBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('Recent Events');
|
|
expect(content).toContain('w-alice');
|
|
expect(content).toContain('w-bob');
|
|
});
|
|
|
|
it('should show stack trace if available', () => {
|
|
const eventWithStack = createMockEvent({
|
|
error: 'Error: Connection failed\n at connect (/app/db.js:42:10)\n at main (/app/index.js:10:5)',
|
|
});
|
|
|
|
const group = createMockGroup({ events: [eventWithStack] });
|
|
|
|
errorGroupPanel.updateGroups([group]);
|
|
vi.clearAllMocks(); // Clear mocks before toggling
|
|
errorGroupPanel.toggleExpanded();
|
|
|
|
const content = mockDetailBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('Stack Trace');
|
|
expect(content).toContain('at connect');
|
|
expect(content).toContain('at main');
|
|
});
|
|
|
|
it('should truncate long stack traces', () => {
|
|
const longStack = Array.from({ length: 20 }, (_, i) => ` at function${i} (/app/file.js:${i}:0)`).join('\n');
|
|
const eventWithLongStack = createMockEvent({
|
|
error: `Error: Test\n${longStack}`,
|
|
});
|
|
|
|
const group = createMockGroup({ events: [eventWithLongStack] });
|
|
|
|
errorGroupPanel.updateGroups([group]);
|
|
vi.clearAllMocks(); // Clear mocks before toggling
|
|
errorGroupPanel.toggleExpanded();
|
|
|
|
const content = mockDetailBoxInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('more lines');
|
|
});
|
|
});
|
|
|
|
describe('key bindings', () => {
|
|
it('should bind up and k keys to selectPrevious', () => {
|
|
expect(mockListInstance.key).toHaveBeenCalledWith(['up', 'k'], expect.any(Function));
|
|
});
|
|
|
|
it('should bind down and j keys to selectNext', () => {
|
|
expect(mockListInstance.key).toHaveBeenCalledWith(['down', 'j'], expect.any(Function));
|
|
});
|
|
|
|
it('should bind enter and space to toggleExpanded', () => {
|
|
expect(mockListInstance.key).toHaveBeenCalledWith(['enter', 'space'], expect.any(Function));
|
|
});
|
|
|
|
it('should bind escape key to hide', () => {
|
|
expect(mockListInstance.key).toHaveBeenCalledWith(['escape'], expect.any(Function));
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle groups with very long signatures', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
fingerprint: createMockFingerprint({
|
|
signature: 'This is a very long error signature that should be truncated appropriately',
|
|
}),
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
// Signature should be truncated (40 chars)
|
|
expect(content).toContain('This is a very long error signature that');
|
|
});
|
|
|
|
it('should handle empty worker list', () => {
|
|
const groups = [
|
|
createMockGroup({
|
|
affectedWorkers: [],
|
|
}),
|
|
];
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.updateGroups(groups)).not.toThrow();
|
|
});
|
|
|
|
it('should handle mixed severity levels in one update', () => {
|
|
const groups = [
|
|
createMockGroup({ severity: 'critical' }),
|
|
createMockGroup({ severity: 'high' }),
|
|
createMockGroup({ severity: 'medium' }),
|
|
createMockGroup({ severity: 'low' }),
|
|
];
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.updateGroups(groups)).not.toThrow();
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('CRITICAL (1)');
|
|
expect(content).toContain('HIGH (1)');
|
|
expect(content).toContain('MEDIUM (1)');
|
|
expect(content).toContain('LOW (1)');
|
|
});
|
|
|
|
it('should handle rapid group updates', () => {
|
|
const groups1 = [createMockGroup({ id: 'eg-1' })];
|
|
const groups2 = [createMockGroup({ id: 'eg-2' })];
|
|
const groups3 = [createMockGroup({ id: 'eg-3' })];
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
errorGroupPanel.updateGroups(groups1);
|
|
errorGroupPanel.updateGroups(groups2);
|
|
errorGroupPanel.updateGroups(groups3);
|
|
}).not.toThrow();
|
|
|
|
expect(errorGroupPanel.getSelected()?.id).toBe('eg-3');
|
|
});
|
|
|
|
it('should handle zero-count groups gracefully', () => {
|
|
const groups = [
|
|
createMockGroup({ count: 0, events: [] }),
|
|
];
|
|
|
|
// Should not throw
|
|
expect(() => errorGroupPanel.updateGroups(groups)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('relative time formatting', () => {
|
|
it('should format recent errors correctly', () => {
|
|
const now = Date.now();
|
|
const groups = [
|
|
createMockGroup({
|
|
lastSeen: now - 5000, // 5 seconds ago
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('5s ago');
|
|
});
|
|
|
|
it('should format errors from minutes ago', () => {
|
|
const now = Date.now();
|
|
const groups = [
|
|
createMockGroup({
|
|
lastSeen: now - 120000, // 2 minutes ago
|
|
}),
|
|
];
|
|
|
|
errorGroupPanel.updateGroups(groups);
|
|
|
|
const content = mockListInstance.setContent.mock.calls[0][0];
|
|
expect(content).toContain('2m ago');
|
|
});
|
|
});
|
|
});
|