idle-timer.test.js 6.57 KB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { IdleTimer } = require('../../engine/idle-timer');

describe('IdleTimer', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should fire callback after timeout with no touch', () => {
    const onTimeout = vi.fn();
    const timer = new IdleTimer(1000, onTimeout);
    timer.start();

    vi.advanceTimersByTime(999);
    expect(onTimeout).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(onTimeout).toHaveBeenCalledTimes(1);
  });

  it('should reset on touch and delay timeout', () => {
    const onTimeout = vi.fn();
    const timer = new IdleTimer(1000, onTimeout);
    timer.start();

    // 800ms 过去,touch
    vi.advanceTimersByTime(800);
    timer.touch();

    // 再过 800ms(距 touch 只有 800ms)
    vi.advanceTimersByTime(800);
    expect(onTimeout).not.toHaveBeenCalled();

    // 再过 200ms(距 touch 已有 1000ms)
    vi.advanceTimersByTime(200);
    expect(onTimeout).toHaveBeenCalledTimes(1);
  });

  it('should not fire after cancel', () => {
    const onTimeout = vi.fn();
    const timer = new IdleTimer(1000, onTimeout);
    timer.start();

    vi.advanceTimersByTime(500);
    timer.cancel();

    vi.advanceTimersByTime(1000);
    expect(onTimeout).not.toHaveBeenCalled();
  });

  it('should not re-fire touch after already fired', () => {
    const onTimeout = vi.fn();
    const timer = new IdleTimer(1000, onTimeout);
    timer.start();

    vi.advanceTimersByTime(1000);
    expect(timer.fired).toBe(true);

    // touch after fired should not create new timer
    timer.touch();
    vi.advanceTimersByTime(1000);
    expect(onTimeout).toHaveBeenCalledTimes(1); // 只触发一次
  });

  it('should support multiple touch calls', () => {
    const onTimeout = vi.fn();
    const timer = new IdleTimer(500, onTimeout);
    timer.start();

    // 模拟持续活动:每 200ms touch
    for (let i = 0; i < 10; i++) {
      vi.advanceTimersByTime(200);
      timer.touch();
    }

    // 总共 2000ms 但始终有 touch,不应触发
    expect(onTimeout).not.toHaveBeenCalled();

    // 停止 touch,等待超时
    vi.advanceTimersByTime(500);
    expect(onTimeout).toHaveBeenCalledTimes(1);
  });

  it('should expose label', () => {
    const timer = new IdleTimer(1000, () => { }, { label: 'test-timer' });
    expect(timer.label).toBe('test-timer');
  });
});

describe('ACP Idle Timeout (integration)', () => {
  const { EventEmitter } = require('events');

  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  /**
   * 模拟 _runPromptInReusableSession 的核心逻辑
   * 使用 mock protocol 测试 idle timeout 行为
   */
  function simulateAcpSession(protocol, idleTimeoutMs = 1000) {
    return new Promise((resolve, reject) => {
      let stdout = '';
      let settled = false;

      const idleTimer = new IdleTimer(idleTimeoutMs, () => {
        if (settled) return;
        settled = true;
        idleTimer.cancel();
        protocol.removeAllListeners('session_update');
        const err = new Error(`Idle timeout: no data for ${idleTimeoutMs}ms`);
        err.code = 'IDLE_TIMEOUT';
        reject(err);
      });

      const onSessionUpdate = (params) => {
        idleTimer.touch();
        const text = params.update?.content?.text || '';
        stdout += text;
      };

      protocol.on('session_update', onSessionUpdate);

      idleTimer.start();
      protocol.sendPrompt('test').then(
        () => {
          if (settled) return;
          settled = true;
          idleTimer.cancel();
          protocol.off('session_update', onSessionUpdate);
          resolve(stdout);
        },
        (err) => {
          if (settled) return;
          settled = true;
          idleTimer.cancel();
          protocol.off('session_update', onSessionUpdate);
          reject(err);
        }
      );
    });
  }

  it('should not timeout when stream data flows continuously', async () => {
    const protocol = new EventEmitter();

    // mock sendPrompt: 发送 5 个 chunk 每隔 500ms,然后 resolve
    protocol.sendPrompt = () => new Promise((resolve) => {
      let count = 0;
      const interval = setInterval(() => {
        count++;
        protocol.emit('session_update', {
          update: {
            sessionUpdate: 'agent_message_chunk',
            content: { text: `chunk${count} ` },
          },
        });
        if (count >= 5) {
          clearInterval(interval);
          resolve();
        }
      }, 500);
    });

    const promise = simulateAcpSession(protocol, 1000);

    // 模拟时间流逝:5 个 chunk × 500ms = 2500ms
    for (let i = 0; i < 5; i++) {
      vi.advanceTimersByTime(500);
      // 让 microtask queue 处理
      await vi.advanceTimersByTimeAsync(0);
    }

    const result = await promise;
    expect(result).toBe('chunk1 chunk2 chunk3 chunk4 chunk5 ');
  });

  it('should timeout when stream stops sending data', async () => {
    const protocol = new EventEmitter();

    // mock sendPrompt: 发送 2 个 chunk 然后停止(不 resolve)
    protocol.sendPrompt = () => new Promise((resolve) => {
      let count = 0;
      const interval = setInterval(() => {
        count++;
        protocol.emit('session_update', {
          update: {
            sessionUpdate: 'agent_message_chunk',
            content: { text: `chunk${count} ` },
          },
        });
        if (count >= 2) {
          clearInterval(interval);
          // 故意不 resolve — 模拟挂死
        }
      }, 300);
    });

    const promise = simulateAcpSession(protocol, 1000);

    // 先挂上 catch 防止 unhandled rejection
    const caughtPromise = promise.catch(err => err);

    // 两个 chunk: 300ms + 300ms
    await vi.advanceTimersByTimeAsync(300);
    await vi.advanceTimersByTimeAsync(300);

    // 现在停止推送,等待 idle timeout (1000ms)
    await vi.advanceTimersByTimeAsync(1000);

    const err = await caughtPromise;
    expect(err).toBeInstanceOf(Error);
    expect(err.message).toContain('Idle timeout');
  });

  it('should set IDLE_TIMEOUT error code', async () => {
    const protocol = new EventEmitter();

    // sendPrompt 永远不 resolve
    protocol.sendPrompt = () => new Promise(() => { });

    const promise = simulateAcpSession(protocol, 500);

    // 先挂上 catch 防止 unhandled rejection
    const caughtPromise = promise.catch(err => err);

    await vi.advanceTimersByTimeAsync(500);

    const err = await caughtPromise;
    expect(err).toBeInstanceOf(Error);
    expect(err.code).toBe('IDLE_TIMEOUT');
  });
});