Added extensive test coverage for error grouping functionality: - Error categorization edge cases (syntax priority, case insensitivity) - Advanced normalization tests (timestamps, hex strings, large numbers, UUIDs) - Category-specific normalizer tests (network, timeout, resource, validation) - Error group manager edge cases (trimming, merging, severity boundaries) - Group management edge cases (rapid concurrent errors, mixed types, unique IDs) - Statistics edge cases (empty manager, all categories) - Time-based behavior tests (active/inactive transitions, severity downgrades) Total: 65 passing tests covering error clustering logic, similarity detection, group merging, and edge cases with different error patterns. Co-Authored-By: Claude Worker <noreply@anthropic.com>
914 lines
30 KiB
TypeScript
914 lines
30 KiB
TypeScript
/**
|
|
* Tests for Error Grouping Module
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
categorizeError,
|
|
fingerprintError,
|
|
fingerprintsMatch,
|
|
ErrorGroupManager,
|
|
getErrorGroupManager,
|
|
resetErrorGroupManager,
|
|
} from './errorGrouping.js';
|
|
import { LogEvent, ErrorCategory } from './types.js';
|
|
|
|
describe('categorizeError', () => {
|
|
it('should categorize network errors', () => {
|
|
expect(categorizeError('ECONNREFUSED')).toBe('network');
|
|
expect(categorizeError('Connection refused to 192.168.1.1:8080')).toBe('network');
|
|
expect(categorizeError('ETIMEDOUT connecting to server')).toBe('network');
|
|
expect(categorizeError('DNS lookup failed')).toBe('network');
|
|
expect(categorizeError('socket hang up')).toBe('network');
|
|
});
|
|
|
|
it('should categorize permission errors', () => {
|
|
expect(categorizeError('EACCES: permission denied')).toBe('permission');
|
|
expect(categorizeError('Access denied for user admin')).toBe('permission');
|
|
expect(categorizeError('Unauthorized (401)')).toBe('permission');
|
|
expect(categorizeError('Forbidden: insufficient permissions')).toBe('permission');
|
|
});
|
|
|
|
it('should categorize not found errors', () => {
|
|
expect(categorizeError('ENOENT: no such file')).toBe('not_found');
|
|
expect(categorizeError('404 Not Found')).toBe('not_found');
|
|
expect(categorizeError('Resource does not exist')).toBe('not_found');
|
|
});
|
|
|
|
it('should categorize timeout errors', () => {
|
|
expect(categorizeError('ETIMEDOUT')).toBe('timeout');
|
|
expect(categorizeError('Request timeout after 30000ms')).toBe('timeout');
|
|
expect(categorizeError('Timeout expired')).toBe('timeout');
|
|
});
|
|
|
|
it('should categorize resource errors', () => {
|
|
expect(categorizeError('ENOMEM: out of memory')).toBe('resource');
|
|
expect(categorizeError('Disk full, no space left')).toBe('resource');
|
|
expect(categorizeError('Rate limit exceeded (429)')).toBe('resource');
|
|
expect(categorizeError('Quota exceeded for user')).toBe('resource');
|
|
});
|
|
|
|
it('should categorize validation errors', () => {
|
|
expect(categorizeError('Invalid input format')).toBe('validation');
|
|
expect(categorizeError('Cannot read property "x" of undefined')).toBe('validation');
|
|
expect(categorizeError('Expected string but got number')).toBe('validation');
|
|
expect(categorizeError('undefined is not a function')).toBe('validation');
|
|
});
|
|
|
|
it('should categorize syntax errors', () => {
|
|
expect(categorizeError('SyntaxError: Unexpected token')).toBe('syntax');
|
|
expect(categorizeError('JSON parse error at line 42')).toBe('syntax');
|
|
expect(categorizeError('YAML parse error')).toBe('syntax');
|
|
expect(categorizeError('invalid format: malformed input')).toBe('syntax');
|
|
});
|
|
|
|
it('should prioritize syntax over validation for SyntaxError', () => {
|
|
// SyntaxError contains "unexpected token" which matches validation pattern
|
|
// but should be categorized as syntax because it's checked first
|
|
expect(categorizeError('SyntaxError: unexpected token')).toBe('syntax');
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
expect(categorizeError('econnrefused')).toBe('network');
|
|
expect(categorizeError('PERMISSION DENIED')).toBe('permission');
|
|
expect(categorizeError('Timeout Expired')).toBe('timeout');
|
|
});
|
|
|
|
it('should categorize tool errors', () => {
|
|
expect(categorizeError('Tool execution failed')).toBe('tool');
|
|
expect(categorizeError('Command failed with exit code 1')).toBe('tool');
|
|
expect(categorizeError('spawn child process error')).toBe('tool');
|
|
});
|
|
|
|
it('should return unknown for unrecognized errors', () => {
|
|
expect(categorizeError('Something weird happened')).toBe('unknown');
|
|
expect(categorizeError('Oops')).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('fingerprintError', () => {
|
|
const createErrorEvent = (msg: string, error?: string): LogEvent => ({
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg,
|
|
error,
|
|
});
|
|
|
|
it('should create consistent fingerprints for similar errors', () => {
|
|
const event1 = createErrorEvent('Connection refused', 'ECONNREFUSED 192.168.1.1:8080');
|
|
const event2 = createErrorEvent('Connection refused', 'ECONNREFUSED 10.0.0.1:3000');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
expect(fp1.category).toBe('network');
|
|
expect(fp2.category).toBe('network');
|
|
});
|
|
|
|
it('should create different fingerprints for different error types', () => {
|
|
const event1 = createErrorEvent('Error', 'ECONNREFUSED');
|
|
const event2 = createErrorEvent('Error', 'Permission denied');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
expect(fp1.hash).not.toBe(fp2.hash);
|
|
expect(fp1.category).toBe('network');
|
|
expect(fp2.category).toBe('permission');
|
|
});
|
|
|
|
it('should normalize IP addresses', () => {
|
|
const event = createErrorEvent('Network error', 'ECONNREFUSED 192.168.1.100:443');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
// IP addresses should be normalized to wildcards
|
|
expect(fp.signature).toMatch(/\*:\*/);
|
|
expect(fp.category).toBe('network');
|
|
});
|
|
|
|
it('should normalize file paths', () => {
|
|
const event = createErrorEvent('File error', 'ENOENT: no such file "/home/user/data.json"');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.signature).not.toContain('/home/user/data.json');
|
|
expect(fp.category).toBe('not_found');
|
|
});
|
|
|
|
it('should normalize UUIDs', () => {
|
|
const event = createErrorEvent('Error', 'Invalid UUID 123e4567-e89b-12d3-a456-426614174000');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.signature).not.toContain('123e4567-e89b-12d3-a456-426614174000');
|
|
expect(fp.signature).toContain('*UUID*');
|
|
});
|
|
|
|
it('should use event.msg when event.error is not present', () => {
|
|
const event = createErrorEvent('ECONNREFUSED connection failed');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.sampleMessage).toBe('ECONNREFUSED connection failed');
|
|
expect(fp.category).toBe('network');
|
|
});
|
|
|
|
it('should truncate long signatures', () => {
|
|
const longMessage = 'ECONNREFUSED ' + 'x'.repeat(300);
|
|
const event = createErrorEvent('Error', longMessage);
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.signature.length).toBeLessThanOrEqual(203); // 200 + '...'
|
|
});
|
|
|
|
it('should remove stack traces from signature', () => {
|
|
const event = createErrorEvent('Error', 'TypeError: Cannot read property "x"\n at Object.foo\n at bar');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.signature).not.toContain('at Object.foo');
|
|
expect(fp.signature).toContain('TypeError');
|
|
});
|
|
|
|
it('should normalize year in timestamps', () => {
|
|
const event1 = createErrorEvent('Error', 'Error at 2024-01-15T10:30:00 occurred');
|
|
const event2 = createErrorEvent('Error', 'Error at 2024-01-15T10:30:00 occurred');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Years (4+ digit numbers) get normalized to *
|
|
expect(fp1.signature).not.toContain('2024');
|
|
expect(fp1.hash).toBe(fp2.hash); // Identical errors should have same hash
|
|
});
|
|
|
|
it('should normalize hex strings', () => {
|
|
const event1 = createErrorEvent('Error', 'Memory address 0x7fff5fbff8a0 invalid');
|
|
const event2 = createErrorEvent('Error', 'Memory address 0x7fff5fbff123 invalid');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
expect(fp1.signature).toContain('*HEX*');
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
});
|
|
|
|
it('should normalize large numbers', () => {
|
|
const event1 = createErrorEvent('Error', 'Request ID 123456789 failed');
|
|
const event2 = createErrorEvent('Error', 'Request ID 987654321 failed');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
expect(fp1.hash).toBe(fp2.hash); // Large numbers should be normalized
|
|
});
|
|
|
|
it('should apply category-specific normalizers for network errors', () => {
|
|
const event1 = createErrorEvent('Error', 'ECONNREFUSED example.com:443');
|
|
const event2 = createErrorEvent('Error', 'ECONNREFUSED other.com:8080');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Hostnames should be normalized to *:*
|
|
expect(fp1.category).toBe('network');
|
|
expect(fp2.category).toBe('network');
|
|
expect(fp1.signature).toContain('*:*');
|
|
expect(fp2.signature).toContain('*:*');
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
});
|
|
|
|
it('should apply category-specific normalizers for timeout errors', () => {
|
|
const event1 = createErrorEvent('Error', 'Request timed out after 5000ms');
|
|
const event2 = createErrorEvent('Error', 'Request timed out after 10000ms');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Should be categorized as timeout
|
|
expect(fp1.category).toBe('timeout');
|
|
expect(fp2.category).toBe('timeout');
|
|
|
|
// Durations should be normalized
|
|
expect(fp1.signature).toMatch(/\*ms/);
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
});
|
|
|
|
it('should apply category-specific normalizers for resource errors', () => {
|
|
const event1 = createErrorEvent('Error', 'Out of memory: 512MB allocated');
|
|
const event2 = createErrorEvent('Error', 'Out of memory: 1024MB allocated');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Memory amounts should be normalized
|
|
expect(fp1.signature).toMatch(/\*B/);
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
});
|
|
|
|
it('should apply category-specific normalizers for validation errors', () => {
|
|
const event1 = createErrorEvent('Error', 'Cannot read property "user.name"');
|
|
const event2 = createErrorEvent('Error', 'Cannot read property "order.id"');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Property names should be normalized
|
|
expect(fp1.hash).toBe(fp2.hash);
|
|
});
|
|
|
|
it('should create different fingerprints for errors with same text but different categories', () => {
|
|
// "ETIMEDOUT connecting" matches network (higher priority)
|
|
// "ETIMEDOUT" alone matches timeout
|
|
const event1 = createErrorEvent('Error', 'ETIMEDOUT connecting to server');
|
|
const event2 = createErrorEvent('Error', 'ETIMEDOUT request');
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
// Both should be categorized as either network or timeout
|
|
// The key is that category is part of the hash
|
|
expect(fp1.category).toBe('network');
|
|
expect(fp2.category).toBe('timeout');
|
|
expect(fp1.hash).not.toBe(fp2.hash); // Different category = different hash
|
|
});
|
|
|
|
it('should handle empty error messages', () => {
|
|
const event = createErrorEvent('', '');
|
|
|
|
const fp = fingerprintError(event);
|
|
|
|
expect(fp.category).toBe('unknown');
|
|
expect(fp.signature).toBe('');
|
|
});
|
|
|
|
it('should handle multiline errors with complex stack traces', () => {
|
|
const complexError = `Error: Cannot connect to database
|
|
at DatabaseManager.connect (/app/db.js:42:15)
|
|
at async Server.initialize (/app/server.js:18:5)
|
|
at async main (/app/index.js:10:3)`;
|
|
|
|
const event = createErrorEvent('Error', complexError);
|
|
const fp = fingerprintError(event);
|
|
|
|
// Should only include first line
|
|
expect(fp.signature).toBe('Error: Cannot connect to database');
|
|
expect(fp.signature).not.toContain('DatabaseManager');
|
|
});
|
|
});
|
|
|
|
describe('fingerprintsMatch', () => {
|
|
it('should return true for matching fingerprints', () => {
|
|
const event: LogEvent = {
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'ECONNREFUSED',
|
|
};
|
|
|
|
const fp1 = fingerprintError(event);
|
|
const fp2 = fingerprintError(event);
|
|
|
|
expect(fingerprintsMatch(fp1, fp2)).toBe(true);
|
|
});
|
|
|
|
it('should return false for different fingerprints', () => {
|
|
const event1: LogEvent = {
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'ECONNREFUSED',
|
|
};
|
|
const event2: LogEvent = {
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'Permission denied',
|
|
};
|
|
|
|
const fp1 = fingerprintError(event1);
|
|
const fp2 = fingerprintError(event2);
|
|
|
|
expect(fingerprintsMatch(fp1, fp2)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ErrorGroupManager', () => {
|
|
let manager: ErrorGroupManager;
|
|
|
|
beforeEach(() => {
|
|
manager = new ErrorGroupManager();
|
|
});
|
|
|
|
const createErrorEvent = (msg: string, worker = 'w-test'): LogEvent => ({
|
|
ts: Date.now(),
|
|
worker,
|
|
level: 'error',
|
|
msg,
|
|
});
|
|
|
|
describe('addError', () => {
|
|
it('should create a new group for first error', () => {
|
|
const event = createErrorEvent('ECONNREFUSED');
|
|
const group = manager.addError(event);
|
|
|
|
expect(group.count).toBe(1);
|
|
expect(group.events).toHaveLength(1);
|
|
expect(group.fingerprint.category).toBe('network');
|
|
expect(group.affectedWorkers).toContain('w-test');
|
|
});
|
|
|
|
it('should add to existing group for similar errors', () => {
|
|
const event1 = createErrorEvent('ECONNREFUSED 192.168.1.1:8080');
|
|
const event2 = createErrorEvent('ECONNREFUSED 10.0.0.1:3000');
|
|
|
|
const group1 = manager.addError(event1);
|
|
const group2 = manager.addError(event2);
|
|
|
|
expect(group1.id).toBe(group2.id);
|
|
expect(group2.count).toBe(2);
|
|
});
|
|
|
|
it('should track multiple workers', () => {
|
|
const event1 = createErrorEvent('ECONNREFUSED', 'w-worker1');
|
|
const event2 = createErrorEvent('ECONNREFUSED', 'w-worker2');
|
|
const event3 = createErrorEvent('ECONNREFUSED', 'w-worker1');
|
|
|
|
manager.addError(event1);
|
|
manager.addError(event2);
|
|
const group = manager.addError(event3);
|
|
|
|
expect(group.affectedWorkers).toHaveLength(2);
|
|
expect(group.affectedWorkers).toContain('w-worker1');
|
|
expect(group.affectedWorkers).toContain('w-worker2');
|
|
expect(group.count).toBe(3);
|
|
});
|
|
|
|
it('should create separate groups for different error types', () => {
|
|
const event1 = createErrorEvent('ECONNREFUSED');
|
|
const event2 = createErrorEvent('Permission denied');
|
|
|
|
const group1 = manager.addError(event1);
|
|
const group2 = manager.addError(event2);
|
|
|
|
expect(group1.id).not.toBe(group2.id);
|
|
expect(manager.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getGroups', () => {
|
|
it('should return all groups sorted by severity and activity', () => {
|
|
// Add multiple errors of different types
|
|
for (let i = 0; i < 10; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent('Permission denied'));
|
|
}
|
|
manager.addError(createErrorEvent('Timeout'));
|
|
|
|
const groups = manager.getGroups();
|
|
|
|
// First group should be the one with most occurrences (network)
|
|
expect(groups[0].count).toBe(10);
|
|
expect(groups[0].fingerprint.category).toBe('network');
|
|
});
|
|
});
|
|
|
|
describe('getActiveGroups', () => {
|
|
it('should return only active groups', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
|
|
const activeGroups = manager.getActiveGroups();
|
|
|
|
expect(activeGroups).toHaveLength(1);
|
|
});
|
|
|
|
it('should not return inactive groups', () => {
|
|
// Create manager with very short active window
|
|
const shortManager = new ErrorGroupManager({ activeWindowMs: 1 });
|
|
const event: LogEvent = {
|
|
ts: Date.now() - 10000, // 10 seconds ago
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'ECONNREFUSED',
|
|
};
|
|
|
|
shortManager.addError(event);
|
|
const activeGroups = shortManager.getActiveGroups();
|
|
|
|
expect(activeGroups).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('getWorkerGroups', () => {
|
|
it('should return groups affecting specific worker', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker1'));
|
|
manager.addError(createErrorEvent('Permission denied', 'w-worker2'));
|
|
|
|
const worker1Groups = manager.getWorkerGroups('w-worker1');
|
|
const worker2Groups = manager.getWorkerGroups('w-worker2');
|
|
|
|
expect(worker1Groups).toHaveLength(1);
|
|
expect(worker1Groups[0].fingerprint.category).toBe('network');
|
|
|
|
expect(worker2Groups).toHaveLength(1);
|
|
expect(worker2Groups[0].fingerprint.category).toBe('permission');
|
|
});
|
|
});
|
|
|
|
describe('getGroupsByCategory', () => {
|
|
it('should return groups by category', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED connection refused'));
|
|
manager.addError(createErrorEvent('Permission denied access error'));
|
|
manager.addError(createErrorEvent('File not found'));
|
|
|
|
const networkGroups = manager.getGroupsByCategory('network');
|
|
const permissionGroups = manager.getGroupsByCategory('permission');
|
|
const notFoundGroups = manager.getGroupsByCategory('not_found');
|
|
|
|
expect(networkGroups).toHaveLength(1);
|
|
expect(permissionGroups).toHaveLength(1);
|
|
expect(notFoundGroups).toHaveLength(1);
|
|
expect(manager.size).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('getStats', () => {
|
|
it('should return correct statistics', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
for (let i = 0; i < 3; i++) {
|
|
manager.addError(createErrorEvent('Permission denied'));
|
|
}
|
|
|
|
const stats = manager.getStats();
|
|
|
|
expect(stats.totalGroups).toBe(2);
|
|
expect(stats.totalErrors).toBe(8);
|
|
expect(stats.activeGroups).toBe(2);
|
|
expect(stats.byCategory.network).toBe(1);
|
|
expect(stats.byCategory.permission).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('should clear all groups', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
manager.addError(createErrorEvent('Permission denied'));
|
|
|
|
expect(manager.size).toBe(2);
|
|
|
|
manager.clear();
|
|
|
|
expect(manager.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('maxGroups option', () => {
|
|
it('should trim groups when exceeding maxGroups', () => {
|
|
const smallManager = new ErrorGroupManager({ maxGroups: 5 });
|
|
|
|
// Add 10 different error types
|
|
for (let i = 0; i < 10; i++) {
|
|
smallManager.addError(createErrorEvent(`Unique error ${i}`));
|
|
}
|
|
|
|
expect(smallManager.size).toBeLessThanOrEqual(5);
|
|
});
|
|
});
|
|
|
|
describe('severity calculation', () => {
|
|
it('should increase severity with count', () => {
|
|
const manager = new ErrorGroupManager({
|
|
highSeverityThreshold: 3,
|
|
criticalSeverityThreshold: 5,
|
|
});
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
|
|
const groups = manager.getGroups();
|
|
expect(groups[0].severity).toBe('critical');
|
|
});
|
|
|
|
it('should handle boundary conditions for severity thresholds', () => {
|
|
const manager = new ErrorGroupManager({
|
|
highSeverityThreshold: 5,
|
|
criticalSeverityThreshold: 10,
|
|
});
|
|
|
|
// 1 error = low
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
expect(manager.getGroups()[0].severity).toBe('low');
|
|
|
|
// 2 errors = medium
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
expect(manager.getGroups()[0].severity).toBe('medium');
|
|
|
|
// 5 errors = high (exactly at threshold)
|
|
for (let i = 0; i < 3; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
expect(manager.getGroups()[0].severity).toBe('high');
|
|
|
|
// 10 errors = critical (exactly at threshold)
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
expect(manager.getGroups()[0].severity).toBe('critical');
|
|
});
|
|
|
|
it('should downgrade severity for inactive errors', () => {
|
|
const manager = new ErrorGroupManager({
|
|
activeWindowMs: 100, // 100ms window
|
|
highSeverityThreshold: 3,
|
|
});
|
|
|
|
// Add high-severity errors
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
}
|
|
|
|
expect(manager.getGroups()[0].severity).toBe('high');
|
|
|
|
// Wait for errors to become inactive
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
const groups = manager.getGroups();
|
|
expect(groups[0].isActive).toBe(false);
|
|
expect(groups[0].severity).toBe('low'); // Should downgrade to low
|
|
resolve();
|
|
}, 150);
|
|
});
|
|
});
|
|
|
|
it('should update severity on each new error', () => {
|
|
const manager = new ErrorGroupManager({
|
|
highSeverityThreshold: 3,
|
|
criticalSeverityThreshold: 5,
|
|
});
|
|
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
expect(manager.getGroups()[0].severity).toBe('low');
|
|
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
expect(manager.getGroups()[0].severity).toBe('medium');
|
|
|
|
manager.addError(createErrorEvent('ECONNREFUSED'));
|
|
expect(manager.getGroups()[0].severity).toBe('high');
|
|
});
|
|
});
|
|
|
|
describe('group trimming', () => {
|
|
it('should handle exactly maxGroups limit', () => {
|
|
const manager = new ErrorGroupManager({ maxGroups: 5 });
|
|
|
|
// Add exactly maxGroups
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.addError(createErrorEvent(`Error ${i}`));
|
|
}
|
|
|
|
expect(manager.size).toBe(5);
|
|
|
|
// Add one more to trigger trimming
|
|
manager.addError(createErrorEvent('Error 6'));
|
|
|
|
expect(manager.size).toBeLessThanOrEqual(5);
|
|
});
|
|
|
|
it('should prioritize removing inactive groups', () => {
|
|
const manager = new ErrorGroupManager({
|
|
maxGroups: 3,
|
|
activeWindowMs: 100,
|
|
});
|
|
|
|
// Add old inactive errors
|
|
const oldEvent: LogEvent = {
|
|
ts: Date.now() - 10000, // 10 seconds ago
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'Old error 1',
|
|
};
|
|
manager.addError(oldEvent);
|
|
|
|
const oldEvent2: LogEvent = {
|
|
ts: Date.now() - 10000,
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'Old error 2',
|
|
};
|
|
manager.addError(oldEvent2);
|
|
|
|
// Add recent active errors
|
|
manager.addError(createErrorEvent('Recent error 1'));
|
|
manager.addError(createErrorEvent('Recent error 2'));
|
|
|
|
// Adding another should trim old ones first
|
|
const groups = manager.getGroups();
|
|
const activeCount = groups.filter(g => g.isActive).length;
|
|
|
|
// Should have more active than inactive groups
|
|
expect(activeCount).toBeGreaterThan(manager.size - activeCount);
|
|
});
|
|
|
|
it('should remove oldest when all groups are active', () => {
|
|
const manager = new ErrorGroupManager({ maxGroups: 3 });
|
|
|
|
manager.addError(createErrorEvent('Error 1'));
|
|
|
|
// Wait 10ms before adding next
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
manager.addError(createErrorEvent('Error 2'));
|
|
|
|
setTimeout(() => {
|
|
manager.addError(createErrorEvent('Error 3'));
|
|
|
|
setTimeout(() => {
|
|
// All should be active and present
|
|
expect(manager.size).toBe(3);
|
|
|
|
// Add 4th error - should remove oldest (Error 1)
|
|
manager.addError(createErrorEvent('Error 4'));
|
|
|
|
expect(manager.size).toBeLessThanOrEqual(3);
|
|
|
|
// Error 1 should be gone, Error 4 should be present
|
|
const groups = manager.getGroups();
|
|
const messages = groups.map(g => g.fingerprint.sampleMessage);
|
|
expect(messages).not.toContain('Error 1');
|
|
|
|
resolve();
|
|
}, 10);
|
|
}, 10);
|
|
}, 10);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('group merging', () => {
|
|
it('should merge errors with similar patterns but different values', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED 192.168.1.1:8080'));
|
|
manager.addError(createErrorEvent('ECONNREFUSED 10.0.0.1:3000'));
|
|
manager.addError(createErrorEvent('ECONNREFUSED example.com:443'));
|
|
|
|
expect(manager.size).toBe(1); // All should merge into one group
|
|
expect(manager.getGroups()[0].count).toBe(3);
|
|
});
|
|
|
|
it('should update lastSeen timestamp when merging', () => {
|
|
const event1: LogEvent = {
|
|
ts: 1000,
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'ECONNREFUSED 192.168.1.1:8080',
|
|
};
|
|
|
|
const event2: LogEvent = {
|
|
ts: 2000,
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'ECONNREFUSED 10.0.0.1:3000',
|
|
};
|
|
|
|
manager.addError(event1);
|
|
const group1 = manager.getGroups()[0];
|
|
expect(group1.lastSeen).toBe(1000);
|
|
|
|
manager.addError(event2);
|
|
const group2 = manager.getGroups()[0];
|
|
expect(group2.lastSeen).toBe(2000);
|
|
});
|
|
|
|
it('should not duplicate workers in affectedWorkers', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker1'));
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker1'));
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker2'));
|
|
|
|
const group = manager.getGroups()[0];
|
|
expect(group.affectedWorkers).toHaveLength(2);
|
|
expect(group.affectedWorkers.filter(w => w === 'w-worker1')).toHaveLength(1);
|
|
});
|
|
|
|
it('should preserve all events when merging', () => {
|
|
const event1 = createErrorEvent('ECONNREFUSED 192.168.1.1:8080');
|
|
const event2 = createErrorEvent('ECONNREFUSED 10.0.0.1:3000');
|
|
const event3 = createErrorEvent('ECONNREFUSED example.com:443');
|
|
|
|
manager.addError(event1);
|
|
manager.addError(event2);
|
|
manager.addError(event3);
|
|
|
|
const group = manager.getGroups()[0];
|
|
expect(group.events).toHaveLength(3);
|
|
expect(group.events[0]).toBe(event1);
|
|
expect(group.events[1]).toBe(event2);
|
|
expect(group.events[2]).toBe(event3);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle rapid concurrent errors from same worker', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker1'));
|
|
}
|
|
|
|
expect(manager.size).toBe(1);
|
|
expect(manager.getGroups()[0].count).toBe(100);
|
|
expect(manager.getGroups()[0].affectedWorkers).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle mixed error types from multiple workers', () => {
|
|
const errorTypes = [
|
|
'ECONNREFUSED',
|
|
'Permission denied',
|
|
'File not found',
|
|
'Timeout',
|
|
'Out of memory',
|
|
];
|
|
const workers = ['w-1', 'w-2', 'w-3'];
|
|
|
|
// Create a mix of errors
|
|
for (let i = 0; i < 50; i++) {
|
|
const errorType = errorTypes[i % errorTypes.length];
|
|
const worker = workers[i % workers.length];
|
|
manager.addError(createErrorEvent(errorType, worker));
|
|
}
|
|
|
|
expect(manager.size).toBe(5); // One group per error type
|
|
|
|
// Each group should have all 3 workers
|
|
manager.getGroups().forEach(group => {
|
|
expect(group.affectedWorkers).toHaveLength(3);
|
|
expect(group.count).toBe(10); // 50 total / 5 types
|
|
});
|
|
});
|
|
|
|
it('should generate unique group IDs', () => {
|
|
const ids = new Set<string>();
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const group = manager.addError(createErrorEvent(`Unique error ${i}`));
|
|
ids.add(group.id);
|
|
}
|
|
|
|
// All IDs should be unique
|
|
expect(ids.size).toBeGreaterThanOrEqual(manager.size);
|
|
});
|
|
|
|
it('should handle errors with no message', () => {
|
|
const event: LogEvent = {
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: '',
|
|
};
|
|
|
|
const group = manager.addError(event);
|
|
|
|
expect(group).toBeDefined();
|
|
expect(group.count).toBe(1);
|
|
});
|
|
|
|
it('should handle getGroup with non-existent ID', () => {
|
|
const group = manager.getGroup('non-existent-id');
|
|
expect(group).toBeUndefined();
|
|
});
|
|
|
|
it('should handle getWorkerGroups with no matches', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED', 'w-worker1'));
|
|
|
|
const groups = manager.getWorkerGroups('w-nonexistent');
|
|
expect(groups).toHaveLength(0);
|
|
});
|
|
|
|
it('should handle getGroupsByCategory with no matches', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED')); // network
|
|
|
|
const groups = manager.getGroupsByCategory('permission');
|
|
expect(groups).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('statistics edge cases', () => {
|
|
it('should return zero stats for empty manager', () => {
|
|
const stats = manager.getStats();
|
|
|
|
expect(stats.totalGroups).toBe(0);
|
|
expect(stats.activeGroups).toBe(0);
|
|
expect(stats.totalErrors).toBe(0);
|
|
|
|
Object.values(stats.byCategory).forEach(count => {
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
Object.values(stats.bySeverity).forEach(count => {
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('should count all categories correctly', () => {
|
|
manager.addError(createErrorEvent('ECONNREFUSED')); // network
|
|
manager.addError(createErrorEvent('Permission denied')); // permission
|
|
manager.addError(createErrorEvent('File not found')); // not_found
|
|
manager.addError(createErrorEvent('Request timed out')); // timeout
|
|
manager.addError(createErrorEvent('Out of memory')); // resource
|
|
manager.addError(createErrorEvent('Invalid input')); // validation
|
|
manager.addError(createErrorEvent('SyntaxError')); // syntax
|
|
manager.addError(createErrorEvent('Tool failed')); // tool
|
|
manager.addError(createErrorEvent('Something unknown happened')); // unknown
|
|
|
|
const stats = manager.getStats();
|
|
|
|
expect(stats.totalGroups).toBe(9);
|
|
expect(stats.byCategory.network).toBe(1);
|
|
expect(stats.byCategory.permission).toBe(1);
|
|
expect(stats.byCategory.not_found).toBe(1);
|
|
expect(stats.byCategory.timeout).toBe(1);
|
|
expect(stats.byCategory.resource).toBe(1);
|
|
expect(stats.byCategory.validation).toBe(1);
|
|
expect(stats.byCategory.syntax).toBe(1);
|
|
expect(stats.byCategory.tool).toBe(1);
|
|
expect(stats.byCategory.unknown).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Global manager', () => {
|
|
beforeEach(() => {
|
|
resetErrorGroupManager();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetErrorGroupManager();
|
|
});
|
|
|
|
it('should return singleton instance', () => {
|
|
const manager1 = getErrorGroupManager();
|
|
const manager2 = getErrorGroupManager();
|
|
|
|
expect(manager1).toBe(manager2);
|
|
});
|
|
|
|
it('should reset singleton', () => {
|
|
const manager1 = getErrorGroupManager();
|
|
manager1.addError({
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
level: 'error',
|
|
msg: 'Test error',
|
|
});
|
|
|
|
resetErrorGroupManager();
|
|
|
|
const manager2 = getErrorGroupManager();
|
|
expect(manager2.size).toBe(0);
|
|
});
|
|
});
|