import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventQueue } from '../src/core/EventQueue'; import { EventBus } from '../src/core/EventBus'; import type { SentryEvent, ErrorEvent } from '../src/types'; describe('EventQueue', () => { let eventQueue: EventQueue; let eventBus: EventBus; let flushCallback: ReturnType; let syncFlushCallback: ReturnType; beforeEach(() => { eventBus = new EventBus(); flushCallback = vi.fn().mockResolvedValue(undefined); syncFlushCallback = vi.fn(); eventQueue = new EventQueue(100, 5000, eventBus, flushCallback, syncFlushCallback); }); afterEach(() => { eventQueue.destroy(); vi.clearAllMocks(); }); describe('enqueue', () => { it('should add event to queue', () => { const event = createErrorEvent('Test error'); eventQueue.enqueue(event); expect(eventQueue.size()).toBe(1); }); it('should emit event when enqueueing', () => { const event = createErrorEvent('Test error'); const emitSpy = vi.spyOn(eventBus, 'emit'); eventQueue.enqueue(event); expect(emitSpy).toHaveBeenCalledWith('event', event); }); it('should trigger flush when queue is full', () => { const eventQueueSmall = new EventQueue(2, 5000, eventBus, flushCallback, syncFlushCallback); eventQueueSmall.enqueue(createErrorEvent('error 1')); eventQueueSmall.enqueue(createErrorEvent('error 2')); // flushCallback should be called due to maxSize reached expect(flushCallback).toHaveBeenCalled(); eventQueueSmall.destroy(); }); it('should skip deduplicated events', () => { const event1 = createErrorEvent('Duplicate error', 'fingerprint1'); const event2 = createErrorEvent('Duplicate error', 'fingerprint1'); const event3 = createErrorEvent('Duplicate error', 'fingerprint1'); const event4 = createErrorEvent('Duplicate error', 'fingerprint1'); // count >= 3, should be deduped eventQueue.enqueue(event1); eventQueue.enqueue(event2); eventQueue.enqueue(event3); eventQueue.enqueue(event4); // First 3 events are allowed, 4th is deduped expect(eventQueue.size()).toBe(3); }); it('should not skip non-duplicate events', () => { const event1 = createErrorEvent('Error 1', 'fp1'); const event2 = createErrorEvent('Error 2', 'fp2'); eventQueue.enqueue(event1); eventQueue.enqueue(event2); expect(eventQueue.size()).toBe(2); }); }); describe('flush', () => { it('should call flush callback with events', async () => { const event1 = createErrorEvent('Error 1'); const event2 = createErrorEvent('Error 2'); eventQueue.enqueue(event1); eventQueue.enqueue(event2); await eventQueue.flush(); expect(flushCallback).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ message: 'Error 1' }), expect.objectContaining({ message: 'Error 2' }), ])); }); it('should not flush when queue is empty', async () => { await eventQueue.flush(); expect(flushCallback).not.toHaveBeenCalled(); }); it('should clear queue after successful flush', async () => { const event = createErrorEvent('Test error'); eventQueue.enqueue(event); expect(eventQueue.size()).toBe(1); await eventQueue.flush(); expect(eventQueue.size()).toBe(0); }); it('should return events to queue on flush failure', async () => { flushCallback.mockRejectedValueOnce(new Error('Network error')); const event = createErrorEvent('Test error'); eventQueue.enqueue(event); await expect(eventQueue.flush()).rejects.toThrow('Network error'); // Events should be returned to queue expect(eventQueue.size()).toBe(1); }); it('should prevent concurrent flush reentry', async () => { flushCallback.mockImplementation(async () => { await new Promise(resolve => setTimeout(resolve, 50)); }); const event = createErrorEvent('Test error'); eventQueue.enqueue(event); // Start first flush const flushPromise = eventQueue.flush(); // Try second flush immediately await eventQueue.flush(); await flushPromise; // flushCallback should only be called once expect(flushCallback).toHaveBeenCalledTimes(1); }); }); describe('flushSync', () => { it('should call sync flush callback', () => { const event = createErrorEvent('Sync error'); eventQueue.enqueue(event); eventQueue.flushSync(); expect(syncFlushCallback).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ message: 'Sync error' }), ])); }); it('should return events to queue on sync flush failure', () => { syncFlushCallback.mockImplementationOnce(() => { throw new Error('Sync error'); }); const event = createErrorEvent('Test error'); eventQueue.enqueue(event); eventQueue.flushSync(); // Events should be returned to queue expect(eventQueue.size()).toBe(1); }); }); describe('destroy', () => { it('should clear all data', () => { const event = createErrorEvent('Test error'); eventQueue.enqueue(event); expect(eventQueue.size()).toBe(1); eventQueue.destroy(); expect(eventQueue.size()).toBe(0); }); }); describe('deduplication', () => { it('should generate dedup key from fingerprint first', () => { const event = createErrorEvent('Test error', 'fp1'); eventQueue.enqueue(event); expect(eventQueue.size()).toBe(1); }); it('should generate dedup key from message if no fingerprint', () => { const event1 = createErrorEvent('Same message'); const event2 = createErrorEvent('Same message'); const event3 = createErrorEvent('Same message'); const event4 = createErrorEvent('Same message'); eventQueue.enqueue(event1); eventQueue.enqueue(event2); eventQueue.enqueue(event3); eventQueue.enqueue(event4); expect(eventQueue.size()).toBe(3); }); it('should reset dedup entry after expiry', async () => { const event1 = createErrorEvent('Test', 'fp-expire'); const event2 = createErrorEvent('Test', 'fp-expire'); const event3 = createErrorEvent('Test', 'fp-expire'); eventQueue.enqueue(event1); eventQueue.enqueue(event2); eventQueue.enqueue(event3); expect(eventQueue.size()).toBe(3); // 4th event should be deduped const event4 = createErrorEvent('Test', 'fp-expire'); eventQueue.enqueue(event4); expect(eventQueue.size()).toBe(3); }); }); describe('count events', () => { it('should build count events for deduped errors', async () => { // Enqueue 3 events to reach threshold for (let i = 0; i < 3; i++) { eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp')); } // 4th event is deduped and goes to pendingCounts eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp')); eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp')); expect(eventQueue.size()).toBe(3); // Flush should include count events await eventQueue.flush(); expect(flushCallback).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ type: 'count', fingerprint: 'count-fp', count: 2 }), ]) ); }); it('should flush pending counts even when regular queue is empty', async () => { for (let i = 0; i < 3; i++) { eventQueue.enqueue(createErrorEvent('Dedup error', 'count-only-fp')); } // Add some deduped events (keep below infinite loop threshold of 10) for (let i = 0; i < 5; i++) { eventQueue.enqueue(createErrorEvent('Dedup error', 'count-only-fp')); } // Flush first to clear regular queue await eventQueue.flush(); flushCallback.mockClear(); // Enqueue more deduped events for (let i = 0; i < 3; i++) { eventQueue.enqueue(createErrorEvent('Dedup error 2', 'count-only-fp-2')); } for (let i = 0; i < 2; i++) { eventQueue.enqueue(createErrorEvent('Dedup error 2', 'count-only-fp-2')); } // Flush again - should have count events even with empty regular queue await eventQueue.flush(); const lastCall = flushCallback.mock.calls[flushCallback.mock.calls.length - 1]; const countEvents = lastCall[0].filter((e: any) => e.type === 'count'); expect(countEvents.length).toBeGreaterThan(0); }); }); describe('infinite loop detection', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should pause queue when too many errors in 1 second', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); for (let i = 0; i < 11; i++) { eventQueue.enqueue(createErrorEvent('Infinite loop test', 'infinite-fp')); } // Should have paused expect(consoleWarnSpy).toHaveBeenCalled(); // After pause, no more events should be added const sizeAfterLoop = eventQueue.size(); eventQueue.enqueue(createErrorEvent('After pause', 'infinite-fp')); expect(eventQueue.size()).toBe(sizeAfterLoop); consoleWarnSpy.mockRestore(); }); it('should emit error event on infinite loop detection', () => { const emitSpy = vi.spyOn(eventBus, 'emit'); for (let i = 0; i < 11; i++) { eventQueue.enqueue(createErrorEvent('Infinite loop', 'loop-fp')); } expect(emitSpy).toHaveBeenCalledWith('error', expect.any(Error)); }); it('should resume after pause duration', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); for (let i = 0; i < 11; i++) { eventQueue.enqueue(createErrorEvent('Resume test', 'resume-fp')); } const sizeBeforeResume = eventQueue.size(); // Advance time past the 60s pause vi.advanceTimersByTime(61000); // Should resume accepting events eventQueue.enqueue(createErrorEvent('After resume', 'different-fp')); expect(eventQueue.size()).toBe(sizeBeforeResume + 1); consoleWarnSpy.mockRestore(); consoleInfoSpy.mockRestore(); }); }); describe('non-error events', () => { it('should enqueue non-error events without dedup', () => { const transactionEvent: SentryEvent = { type: 'transaction', level: 'info', transaction: 'test-tx', timestamp: Date.now(), } as any; eventQueue.enqueue(transactionEvent); eventQueue.enqueue(transactionEvent); expect(eventQueue.size()).toBe(2); }); }); describe('start/stop timer', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should start timer and flush on interval when queue has events', async () => { const eq = new EventQueue(100, 1000, eventBus, flushCallback, syncFlushCallback); eq.start(); eq.enqueue(createErrorEvent('Interval test')); // flushCallback should not be called yet expect(flushCallback).not.toHaveBeenCalled(); // Advance timer past interval vi.advanceTimersByTime(1500); // Wait for the flush promise to resolve await vi.runOnlyPendingTimersAsync(); expect(flushCallback).toHaveBeenCalled(); eq.destroy(); }); it('should not flush on interval when queue is empty', async () => { const eq = new EventQueue(100, 1000, eventBus, flushCallback, syncFlushCallback); eq.start(); vi.advanceTimersByTime(1500); await vi.runOnlyPendingTimersAsync(); expect(flushCallback).not.toHaveBeenCalled(); eq.destroy(); }); }); }); function createErrorEvent(message: string, fingerprint?: string): ErrorEvent { return { type: 'error', level: 'error', message, timestamp: Date.now(), fingerprint, }; }