Co-authored-by: jason.woltje <[email protected]> Co-committed-by: jason.woltje <[email protected]>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
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();
|
|
});
|
|
});
|