import { AnomalyDetector } from '../../src/core/AnomalyDetector'; import { IObservation, IAction } from '../../src/core/interfaces'; function makeObservation(overrides: Partial = {}): IObservation { return { id: 'obs-1', actionId: 'act-1', newStateId: 'state-2', httpResponses: [], consoleErrors: [], jsExceptions: [], timestamp: Date.now(), ...overrides, }; } function makeAction(id = 'a1'): IAction { return { id, type: 'click', selector: '#btn', timestamp: 1000, seed: 42, stateId: 's1' }; } describe('AnomalyDetector', () => { let detector: AnomalyDetector; const trace = [makeAction()]; beforeEach(() => { detector = new AnomalyDetector(); }); describe('checkHttpErrors', () => { it('returns null when no HTTP errors', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 200, method: 'GET', durationMs: 10 }], }); expect(detector.checkHttpErrors(obs, trace)).toBeNull(); }); it('detects a 4xx error as medium severity', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 404, method: 'GET', durationMs: 10 }], }); const anomaly = detector.checkHttpErrors(obs, trace); expect(anomaly).not.toBeNull(); expect(anomaly!.type).toBe('http_error'); expect(anomaly!.severity).toBe('medium'); }); it('detects a 5xx error as high severity', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }], }); const anomaly = detector.checkHttpErrors(obs, trace); expect(anomaly).not.toBeNull(); expect(anomaly!.severity).toBe('high'); }); it('includes http log in evidence', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }], }); const anomaly = detector.checkHttpErrors(obs, trace)!; expect(anomaly.evidence.httpLog).toHaveLength(1); expect(anomaly.evidence.httpLog![0].status).toBe(500); }); it('includes the full action trace', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 503, method: 'GET', durationMs: 50 }], }); const multiTrace = [makeAction('a1'), makeAction('a2')]; const anomaly = detector.checkHttpErrors(obs, multiTrace)!; expect(anomaly.actionTrace).toHaveLength(2); }); }); describe('checkJsExceptions', () => { it('returns null when no JS exceptions', () => { expect(detector.checkJsExceptions(makeObservation(), trace)).toBeNull(); }); it('detects a JS exception as high severity', () => { const obs = makeObservation({ jsExceptions: ['ReferenceError: foo is not defined'] }); const anomaly = detector.checkJsExceptions(obs, trace); expect(anomaly).not.toBeNull(); expect(anomaly!.type).toBe('js_exception'); expect(anomaly!.severity).toBe('high'); }); it('includes all exceptions in raw errors', () => { const obs = makeObservation({ jsExceptions: ['Error A', 'Error B'] }); const anomaly = detector.checkJsExceptions(obs, trace)!; expect(anomaly.evidence.rawErrors).toEqual(['Error A', 'Error B']); }); }); describe('checkConsoleErrors', () => { it('returns null when no console errors', () => { expect(detector.checkConsoleErrors(makeObservation(), trace)).toBeNull(); }); it('detects console errors as low severity', () => { const obs = makeObservation({ consoleErrors: ['Failed to load resource'] }); const anomaly = detector.checkConsoleErrors(obs, trace); expect(anomaly).not.toBeNull(); expect(anomaly!.type).toBe('console_error'); expect(anomaly!.severity).toBe('low'); }); }); describe('detect (combined)', () => { it('returns empty array when no anomalies', () => { const result = detector.detect(makeObservation(), trace); expect(result).toHaveLength(0); }); it('returns multiple anomalies when multiple rules trigger', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 10 }], jsExceptions: ['TypeError: Cannot read property'], consoleErrors: ['Network error'], }); const result = detector.detect(obs, trace); expect(result).toHaveLength(3); const types = result.map((a) => a.type); expect(types).toContain('http_error'); expect(types).toContain('js_exception'); expect(types).toContain('console_error'); }); it('each anomaly has a unique id', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 500, method: 'POST', durationMs: 10 }], jsExceptions: ['TypeError'], }); const anomalies = detector.detect(obs, trace); const ids = anomalies.map((a) => a.id); expect(new Set(ids).size).toBe(ids.length); }); it('anomalies are JSON-serializable', () => { const obs = makeObservation({ httpResponses: [{ url: '/api', status: 404, method: 'GET', durationMs: 5 }], }); const anomalies = detector.detect(obs, trace); expect(() => JSON.stringify(anomalies)).not.toThrow(); }); }); });