import { afterEach, describe, expect, it, vi } from 'vitest'; import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js'; import type { PresenceHeartbeatContent } from '../types.js'; const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' }; describe('HeartbeatEmitter', () => { it('increments seq starting at 1 and stamps the envelope', () => { let t = 1000; const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t }); const a = em.next(); t = 6000; const b = em.next('away'); expect(a.seq).toBe(1); expect(a.ts).toBe(1000); expect(a.status).toBe('online'); expect(a.macp_type).toBe('presence'); expect(a.msgtype).toBe('mosaic.presence'); expect(a.macp_version).toBe('1.0'); expect(a.interval_ms).toBe(5000); expect(a.agent).toEqual(agent); expect(a.body).toContain('alpha'); expect(b.seq).toBe(2); expect(b.ts).toBe(6000); expect(b.status).toBe('away'); expect(em.currentSeq).toBe(2); }); it('includes mission_id only when provided', () => { const withMission = new HeartbeatEmitter({ agent, intervalMs: 1000, missionId: 'KBN-101', }).next(); const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next(); expect(withMission.mission_id).toBe('KBN-101'); expect(without.mission_id).toBeUndefined(); }); }); describe('startHeartbeatLoop', () => { afterEach(() => vi.useRealTimers()); it('emits immediately, then once per interval, until stopped', () => { vi.useFakeTimers(); const sent: PresenceHeartbeatContent[] = []; const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() }); const loop = startHeartbeatLoop({ emitter: em, intervalMs: 1000, send: (c) => { sent.push(c); }, }); expect(sent).toHaveLength(1); // immediate beat vi.advanceTimersByTime(3000); expect(sent).toHaveLength(4); // +3 beats expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]); loop.stop(); vi.advanceTimersByTime(5000); expect(sent).toHaveLength(4); // no more after stop loop.stop(); // idempotent }); it('routes a rejected async send to onError without killing the loop', async () => { vi.useFakeTimers(); const onError = vi.fn(); let n = 0; const em = new HeartbeatEmitter({ agent, intervalMs: 1000 }); const loop = startHeartbeatLoop({ emitter: em, intervalMs: 1000, onError, send: () => { n += 1; return Promise.reject(new Error(`boom ${n}`)); }, }); await vi.advanceTimersByTimeAsync(2000); // immediate + 2 expect(n).toBe(3); expect(onError).toHaveBeenCalledTimes(3); loop.stop(); }); });