/** * Regression test: disconnect + reconnect 后 pending reply 清理仍生效 * * Bug #1 (架构评审发现): * 旧实现: const cleanupInterval = setInterval(...) 设在 extension 加载时一次 * disconnect() 会 clearInterval(cleanupInterval) * connect() 后没有重新设 interval * 后果: /dingtalkbot-disable + /dingtalkbot-enable 之后, * pending reply 超时清理永久失效 * * 修复: interval 跟 connection 走 — connect() 启动, disconnect() 停止 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as os from 'node:os'; // ============================================================================ // 隔离 homedir — 避免与其他测试文件共享 ~/.pi/agent/dingtalk-bot/ // ============================================================================ let testHome: string; let originalHomedir: typeof os.homedir; // 使用 vi.mock 隔离 os.homedir 返回值 let mockHomedirValue = ''; vi.mock('node:os', async () => { const actual = await vi.importActual('node:os'); return { ...actual, homedir: () => mockHomedirValue, }; }); beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), 'pi-dingtalkbot-test-')); mockHomedirValue = testHome; }); afterEach(() => { try { rmSync(testHome, { recursive: true, force: true }); } catch {} }); // ============================================================================ // Mock 模块 // ============================================================================ const handlers = new Map>(); const registeredTools = new Map(); const allEventHandlers: { event: string; handler: Function }[] = []; function makeMockPi() { return { on: vi.fn((event: string, handler: any) => { if (!handlers.has(event)) handlers.set(event, new Set()); handlers.get(event)!.add(handler); allEventHandlers.push({ event, handler }); }), registerTool: vi.fn((tool: any) => { registeredTools.set(tool.name, tool); }), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), getFlag: vi.fn(() => undefined), getActiveTools: vi.fn(() => []), setActiveTools: vi.fn(), getAllTools: vi.fn(() => []), sendUserMessage: vi.fn(), sendMessage: vi.fn(), exec: vi.fn(), }; } async function emitEventAll(event: string, payload: any): Promise { const hs = handlers.get(event); if (!hs) return; for (const h of hs) await h(payload); } // 跟踪每个 interval 的真实创建/清除状态 let intervalIds = new Set(); let clearedIds = new Set(); let mockClient: any = null; let connectCount = 0; let disconnectCount = 0; const realSetInterval = global.setInterval; const realClearInterval = global.clearInterval; vi.mock('dingtalk-stream', () => { const g = globalThis as any; const { EventEmitter } = require('node:events'); return { DWClient: class MockDWClient extends EventEmitter { private _connected = false; constructor(public config: any) { super(); g.__mockDingTalkClient = this; g.__connectCount = (g.__connectCount || 0) + 1; } registerCallbackListener(_topic: string, _handler: any) { this._cbHandler = _handler; } _cbHandler: any = null; async connect() { this._connected = true; this.emit('connect'); } disconnect() { this._connected = false; g.__disconnectCount = (g.__disconnectCount || 0) + 1; } }, TOPIC_ROBOT: '/v1.0/im/bot/messages/get', EventAck: { SUCCESS: { status: 'SUCCESS', message: 'OK' } }, }; }); vi.mock('@mariozechner/pi-ai', () => ({ Type: new Proxy({}, { get: (_, prop: string) => (...args: any[]) => ({ type: String(prop).toLowerCase(), args, _mockType: prop, }), }), })); vi.mock('@mariozechner/pi-coding-agent', () => ({ Type: new Proxy({}, { get: (_, prop: string) => (...args: any[]) => ({ type: String(prop).toLowerCase(), args, _mockType: prop, }), }), })); // 包装 setInterval / clearInterval 来观察 cleanup 行为 function instrumentIntervals() { intervalIds = new Set(); clearedIds = new Set(); // @ts-ignore global.setInterval = vi.fn((handler: any, ms: any, ...args: any[]) => { const id = realSetInterval(handler, ms, ...args); intervalIds.add(id); return id; }); // @ts-ignore global.clearInterval = vi.fn((id: any) => { if (intervalIds.has(id)) clearedIds.add(id); return realClearInterval(id); }); } beforeEach(() => { (globalThis as any).__mockDingTalkClient = null; (globalThis as any).__connectCount = 0; (globalThis as any).__disconnectCount = 0; instrumentIntervals(); }); afterEach(() => { global.setInterval = realSetInterval; global.clearInterval = realClearInterval; }); // ============================================================================ // 加载扩展 // ============================================================================ async function loadExtension(): Promise { handlers.clear(); registeredTools.clear(); allEventHandlers.length = 0; process.env.PI_SESSION_ID = 'test-cleanup-interval'; const fs = await import('node:fs/promises'); const path = await import('node:path'); const baseDir = path.join(testHome, '.pi', 'agent', 'dingtalk-bot'); await fs.mkdir(path.join(baseDir, 'sessions'), { recursive: true }); await fs.writeFile( path.join(baseDir, 'config.json'), JSON.stringify({ bots: [{ clientId: 'cid', clientSecret: 'sec', name: 'bot' }] }, null, '\t') + '\n' ); await fs.writeFile( path.join(baseDir, 'sessions', 'test-cleanup-interval.json'), JSON.stringify({ activeBotId: 'cid', enabled: true }, null, '\t') + '\n' ); const mockPi = makeMockPi(); vi.resetModules(); const extModule = await import('../index.js'); await extModule.default(mockPi as any); return mockPi; } // ============================================================================ // 测试 // ============================================================================ describe('disconnect + reconnect 后 pending reply 清理仍生效', () => { let mockPi: any; beforeEach(async () => { mockPi = await loadExtension(); }); afterEach(() => { delete process.env.PI_SESSION_ID; }); it('session_start 后应设置 cleanup interval', async () => { await emitEventAll('session_start', { type: 'session_start' }); // 等 connect 异步完成 await new Promise(r => setTimeout(r, 20)); expect(intervalIds.size).toBeGreaterThan(0); // 没有任何 interval 被 clear(首次连接) expect(clearedIds.size).toBe(0); }); it('session_shutdown 后 cleanup interval 被清理', async () => { await emitEventAll('session_start', { type: 'session_start' }); await new Promise(r => setTimeout(r, 20)); const beforeClearCount = clearedIds.size; expect(intervalIds.size).toBeGreaterThan(0); await emitEventAll('session_shutdown', { type: 'session_shutdown' }); // 至少有一个 interval 被 clear 了 expect(clearedIds.size).toBeGreaterThan(beforeClearCount); }); it('REGRESSION: disconnect + reconnect 后 cleanup interval 仍在运行', async () => { // 1. 首次连接 await emitEventAll('session_start', { type: 'session_start' }); await new Promise(r => setTimeout(r, 20)); const intervalsAfterFirstConnect = intervalIds.size; expect(intervalsAfterFirstConnect).toBeGreaterThan(0); // 拍下首次连接时的 interval IDs const firstConnectIds = new Set(intervalIds); // 2. disconnect(模拟 /dingtalkbot-disable) await emitEventAll('session_shutdown', { type: 'session_shutdown' }); const clearedAfterDisconnect = clearedIds.size; expect(clearedAfterDisconnect).toBeGreaterThan(0); // 3. 重新连接(模拟 /dingtalkbot-enable) await emitEventAll('session_start', { type: 'session_start' }); await new Promise(r => setTimeout(r, 20)); // ⭐ 关键断言 1: 重连后 interval 总数增加了 — 证明新的 cleanup interval 被创建 // (修复前 intervalIds.size 不变,因为旧实现只在 extension 加载时 setInterval 一次) expect(intervalIds.size).toBeGreaterThan(intervalsAfterFirstConnect); // ⭐ 关键断言 2: 重连后至少有一个全新的、未被 clear 的 interval ID // 证明新的 cleanup 定时器真的在跑(不是只 setInterval 但立刻 clear 了) const newActiveIntervals = Array.from(intervalIds).filter( (id) => !clearedIds.has(id) && !firstConnectIds.has(id) ); expect(newActiveIntervals.length).toBeGreaterThan(0); }); it('验证 cleanup 函数确实在定时器上注册(不是死代码)', async () => { await emitEventAll('session_start', { type: 'session_start' }); await new Promise(r => setTimeout(r, 20)); // 抓取最近注册的 interval 的回调,验证它是 cleanupPendingReplies 类的函数 // (不是任意的其他定时器) // 这里我们检查 interval 的间隔是 PENDING_CLEANUP_INTERVAL (60000ms) // 通过检查 setInterval 的调用参数 const setIntervalCalls = (global.setInterval as any).mock.calls; // 找到用了 60000ms (PENDING_CLEANUP_INTERVAL) 的那次调用 const cleanupCalls = setIntervalCalls.filter((c: any[]) => c[1] === 60000); expect(cleanupCalls.length).toBeGreaterThan(0); }); });