UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

492 lines (405 loc) 15.9 kB
import { describe, expect, it, vi } from 'vitest'; import { type TaskResult, asyncifyPolling } from './asyncifyPolling'; describe('asyncifyPolling', () => { describe('basic functionality', () => { it('should return data when task succeeds immediately', async () => { const mockTask = vi.fn().mockResolvedValue({ status: 'completed', data: 'result' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'result', } as TaskResult<string>); const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, }); expect(result).toBe('result'); expect(mockTask).toHaveBeenCalledTimes(1); expect(mockCheckStatus).toHaveBeenCalledTimes(1); }); it('should poll multiple times until success', async () => { const mockTask = vi .fn() .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'completed', data: 'final-result' }); const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'success', data: 'final-result' }); const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, initialInterval: 10, // fast test }); expect(result).toBe('final-result'); expect(mockTask).toHaveBeenCalledTimes(3); expect(mockCheckStatus).toHaveBeenCalledTimes(3); }); it('should throw error when task fails', async () => { const mockTask = vi.fn().mockResolvedValue({ status: 'failed', error: 'Task failed' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'failed', error: new Error('Task failed'), }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, }), ).rejects.toThrow('Task failed'); expect(mockTask).toHaveBeenCalledTimes(1); }); it('should handle pending status correctly', async () => { const mockTask = vi .fn() .mockResolvedValueOnce({ status: 'processing' }) .mockResolvedValueOnce({ status: 'done' }); const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'success', data: 'completed' }); const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, initialInterval: 10, }); expect(result).toBe('completed'); expect(mockTask).toHaveBeenCalledTimes(2); }); }); describe('retry mechanism', () => { it('should retry with exponential backoff', async () => { const startTime = Date.now(); const mockTask = vi .fn() .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'success', data: 'done' }); await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, initialInterval: 50, backoffMultiplier: 2, maxInterval: 200, }); const elapsed = Date.now() - startTime; // Should wait at least 50ms + 100ms = 150ms expect(elapsed).toBeGreaterThan(140); }); it('should respect maxInterval limit', async () => { const intervals: number[] = []; const originalSetTimeout = global.setTimeout; global.setTimeout = vi.fn((callback, delay) => { intervals.push(delay as number); return originalSetTimeout(callback, 1); // fast execution }) as any; const mockTask = vi .fn() .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'success', data: 'done' }); await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, initialInterval: 100, backoffMultiplier: 3, maxInterval: 200, }); // Intervals should be: 100, 200 (capped), 200 (capped) expect(intervals).toEqual([100, 200, 200]); global.setTimeout = originalSetTimeout; }); it('should stop after maxRetries', async () => { const mockTask = vi.fn().mockResolvedValue({ status: 'pending' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'pending' }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxRetries: 3, initialInterval: 1, }), ).rejects.toThrow(/timeout after 3 attempts/); expect(mockTask).toHaveBeenCalledTimes(3); }); }); describe('error handling', () => { it('should handle consecutive failures', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Network error 1')) .mockRejectedValueOnce(new Error('Network error 2')) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'recovered' }); const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxConsecutiveFailures: 3, initialInterval: 1, }); expect(result).toBe('recovered'); expect(mockTask).toHaveBeenCalledTimes(3); }); it('should throw after maxConsecutiveFailures', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Network error 1')) .mockRejectedValueOnce(new Error('Network error 2')) .mockRejectedValueOnce(new Error('Network error 3')); const mockCheckStatus = vi.fn(); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxConsecutiveFailures: 2, // 允许最多2次连续失败 initialInterval: 1, }), ).rejects.toThrow(/consecutive attempts/); expect(mockTask).toHaveBeenCalledTimes(2); // 第1次失败,第2次失败,然后抛出错误 expect(mockCheckStatus).not.toHaveBeenCalled(); }); it('should reset consecutive failures on success', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Network error 1')) // Failure 1 (consecutiveFailures=1) .mockResolvedValueOnce({ status: 'pending' }) // Success 1 (reset to 0) .mockRejectedValueOnce(new Error('Network error 2')) // Failure 2 (consecutiveFailures=1) .mockRejectedValueOnce(new Error('Network error 3')) // Failure 3 (consecutiveFailures=2) .mockResolvedValueOnce({ status: 'success' }); // Success 2 (return result) const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) // For success 1 .mockReturnValueOnce({ status: 'success', data: 'final' }); // For success 2 const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxConsecutiveFailures: 3, // Allow up to 3 consecutive failures (since there are 2 consecutive failures) initialInterval: 1, }); expect(result).toBe('final'); expect(mockTask).toHaveBeenCalledTimes(5); // Total 5 calls }); }); describe('configuration', () => { it('should use custom intervals and multipliers', async () => { const intervals: number[] = []; const originalSetTimeout = global.setTimeout; global.setTimeout = vi.fn((callback, delay) => { intervals.push(delay as number); return originalSetTimeout(callback, 1); }) as any; const mockTask = vi .fn() .mockResolvedValueOnce({ status: 'pending' }) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi .fn() .mockReturnValueOnce({ status: 'pending' }) .mockReturnValueOnce({ status: 'success', data: 'done' }); await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, initialInterval: 200, backoffMultiplier: 1.2, }); expect(intervals[0]).toBe(200); global.setTimeout = originalSetTimeout; }); it('should accept custom logger function', async () => { const mockLogger = { debug: vi.fn(), error: vi.fn(), }; const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Test error')) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'done' }); await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, logger: mockLogger, maxConsecutiveFailures: 3, initialInterval: 1, }); expect(mockLogger.debug).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); }); }); describe('edge cases', () => { it('should handle immediate failure', async () => { const mockTask = vi.fn().mockResolvedValue({ error: 'immediate failure' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'failed', error: new Error('immediate failure'), }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, }), ).rejects.toThrow('immediate failure'); expect(mockTask).toHaveBeenCalledTimes(1); }); it('should handle task throwing exceptions', async () => { const mockTask = vi.fn().mockRejectedValue(new Error('Task exception')); const mockCheckStatus = vi.fn(); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxConsecutiveFailures: 1, initialInterval: 1, }), ).rejects.toThrow(/consecutive attempts/); }); it('should timeout correctly with maxRetries = 1', async () => { const mockTask = vi.fn().mockResolvedValue({ status: 'pending' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'pending' }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxRetries: 1, initialInterval: 1, }), ).rejects.toThrow(/timeout after 1 attempts/); expect(mockTask).toHaveBeenCalledTimes(1); }); }); describe('custom error handling', () => { it('should allow continuing polling via onPollingError', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Another error')) .mockResolvedValueOnce({ status: 'success' }); const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'final-result' }); const onPollingError = vi.fn().mockReturnValue({ isContinuePolling: true, }); const result = await asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, onPollingError, initialInterval: 1, }); expect(result).toBe('final-result'); expect(mockTask).toHaveBeenCalledTimes(3); expect(onPollingError).toHaveBeenCalledTimes(2); // Check that error context was passed correctly expect(onPollingError).toHaveBeenCalledWith({ error: expect.any(Error), retries: expect.any(Number), consecutiveFailures: expect.any(Number), }); }); it('should stop polling when onPollingError returns false', async () => { const mockTask = vi.fn().mockRejectedValue(new Error('Fatal error')); const mockCheckStatus = vi.fn(); const onPollingError = vi.fn().mockReturnValue({ isContinuePolling: false, }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, onPollingError, initialInterval: 1, }), ).rejects.toThrow('Fatal error'); expect(mockTask).toHaveBeenCalledTimes(1); expect(onPollingError).toHaveBeenCalledTimes(1); expect(mockCheckStatus).not.toHaveBeenCalled(); }); it('should throw custom error when provided by onPollingError', async () => { const mockTask = vi.fn().mockRejectedValue(new Error('Original error')); const mockCheckStatus = vi.fn(); const customError = new Error('Custom error message'); const onPollingError = vi.fn().mockReturnValue({ isContinuePolling: false, error: customError, }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, onPollingError, initialInterval: 1, }), ).rejects.toThrow('Custom error message'); expect(onPollingError).toHaveBeenCalledWith({ error: expect.objectContaining({ message: 'Original error' }), retries: 0, consecutiveFailures: 1, }); }); it('should provide correct context information to onPollingError', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Error 1')) .mockRejectedValueOnce(new Error('Error 2')) .mockRejectedValueOnce(new Error('Error 3')); const mockCheckStatus = vi.fn(); const onPollingError = vi .fn() .mockReturnValueOnce({ isContinuePolling: true }) .mockReturnValueOnce({ isContinuePolling: true }) .mockReturnValueOnce({ isContinuePolling: false }); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, onPollingError, initialInterval: 1, }), ).rejects.toThrow('Error 3'); // Verify context progression expect(onPollingError).toHaveBeenNthCalledWith(1, { error: expect.objectContaining({ message: 'Error 1' }), retries: 0, consecutiveFailures: 1, }); expect(onPollingError).toHaveBeenNthCalledWith(2, { error: expect.objectContaining({ message: 'Error 2' }), retries: 1, consecutiveFailures: 2, }); expect(onPollingError).toHaveBeenNthCalledWith(3, { error: expect.objectContaining({ message: 'Error 3' }), retries: 2, consecutiveFailures: 3, }); }); it('should fall back to default behavior when onPollingError is not provided', async () => { const mockTask = vi .fn() .mockRejectedValueOnce(new Error('Error 1')) .mockRejectedValueOnce(new Error('Error 2')) .mockRejectedValueOnce(new Error('Error 3')); const mockCheckStatus = vi.fn(); await expect( asyncifyPolling({ pollingQuery: mockTask, checkStatus: mockCheckStatus, maxConsecutiveFailures: 2, initialInterval: 1, }), ).rejects.toThrow(/consecutive attempts/); expect(mockTask).toHaveBeenCalledTimes(2); }); }); });