384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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<typeof vi.fn>;
|
|
let syncFlushCallback: ReturnType<typeof vi.fn>;
|
|
|
|
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,
|
|
};
|
|
}
|