/** * MCP 自动重连测试(v0.7.3 P4-2) * * R1 nextRetryDelayMs 退避序列(5s/15s/60s,越界钳制); * R2 连接失败 → 进入 reconnecting 并按退避排程;fake timers 推进后真实重试; * R3 重试耗尽(3 次)→ 停留 error 且不再排程; * R4 disconnectServer(用户显式断开)取消重连排程; * R5 setAutoReconnect(false) 取消全部排程且后续失败不再排程; * R6 连接成功清零计数与排程(connectServer 成功路径经 spy 模拟)。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { MCPManager, MAX_RECONNECT_ATTEMPTS, nextRetryDelayMs } from '../mcp-manager.service'; import type { MCPServerConfig } from '../mcp-manager.service'; import type { ToolRegistry } from '../../harness/tools/registry'; function makeManager(): MCPManager { const fakeDB = { prepare: () => ({ run: () => ({ changes: 0 }) }), } as unknown as ConstructorParameters[0] extends () => infer D ? D : never; const registry = { registerMCP: vi.fn(() => true), unregisterMCPTools: vi.fn(), } as unknown as ToolRegistry; return new MCPManager(() => fakeDB, registry); } const FAILING_CONFIG: MCPServerConfig = { id: 'mcp_test', name: 'broken-server', // stdio 但缺 command —— connectServer 在构造 transport 前显式抛错(不触网、不 spawn) transport: 'stdio', enabled: true, }; describe('nextRetryDelayMs', () => { it('R1: 退避序列 5s/15s/60s,越界钳制', () => { expect(nextRetryDelayMs(1)).toBe(5_000); expect(nextRetryDelayMs(2)).toBe(15_000); expect(nextRetryDelayMs(3)).toBe(60_000); expect(nextRetryDelayMs(4)).toBe(60_000); // 越界钳制到最后一档 expect(nextRetryDelayMs(0)).toBe(5_000); // 下界钳制 }); }); describe('MCPManager 自动重连', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('R2: 连接失败 → reconnecting 状态 + 定时排程;推进后真实重试', async () => { const manager = makeManager(); const connectSpy = vi.spyOn(manager, 'connectServer'); await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); // 第一次失败后:计数 1,状态 reconnecting,已排程 expect(manager.getReconnectInfo('broken-server')).toMatchObject({ attempts: 1, scheduled: true, }); expect(manager.getServerState('broken-server')?.status).toBe('reconnecting'); // 推进 5s → 第二次真实重试(同样失败)→ 计数 2 connectSpy.mockClear(); await vi.advanceTimersByTimeAsync(5_000); expect(connectSpy).toHaveBeenCalledTimes(1); expect(manager.getReconnectInfo('broken-server')).toMatchObject({ attempts: 2, scheduled: true, }); }); it('R3: 重试耗尽(3 次)→ 停留 error 且不再排程', async () => { const manager = makeManager(); await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); // 第 1 次失败后 + 3 次定时重试(每次再失败) await vi.advanceTimersByTimeAsync(5_000); await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(60_000); const info = manager.getReconnectInfo('broken-server'); // 耗尽后:排程清空(计数清零后 getReconnectInfo 返回 null 或 scheduled=false)、状态停留 error expect(info?.scheduled ?? false).toBe(false); expect(manager.getServerState('broken-server')?.status).toBe('error'); // 再推进也不再有动作 const spy = vi.spyOn(manager, 'connectServer'); await vi.advanceTimersByTimeAsync(600_000); expect(spy).not.toHaveBeenCalled(); }); it('R4: disconnectServer(用户显式断开)取消重连排程', async () => { const manager = makeManager(); await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); await manager.disconnectServer('broken-server'); // 簿记清空 → getReconnectInfo 返回 null(等价于无排程) expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); // 状态回落 disconnected expect(manager.getServerState('broken-server')?.status).toBe('disconnected'); // 推进时间不再触发重试 const spy = vi.spyOn(manager, 'connectServer'); await vi.advanceTimersByTimeAsync(600_000); expect(spy).not.toHaveBeenCalled(); }); it('R5: setAutoReconnect(false) 取消全部排程,后续失败不再排程', async () => { const manager = makeManager(); await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); manager.setAutoReconnect(false); expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); // 关闭后再次失败 → 不排程(簿记清空 → getReconnectInfo 返回 null) await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false); expect(manager.getServerState('broken-server')?.status).toBe('error'); }); it('R6: setAutoReconnect(true)(默认)开启语义 —— 失败必排程', async () => { const manager = makeManager(); expect(manager.getReconnectInfo('x')).toBeNull(); await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow(); expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true); expect(MAX_RECONNECT_ATTEMPTS).toBe(3); }); });