test(KB-606): add comprehensive test coverage across packages
- Add tests for core board logic (board.test.ts) - Add tests for dashboard useToast hook (useToast.test.tsx) - Add tests for dashboard model filter utility (modelFilter.test.ts) - Add tests for agent heartbeat monitor (agent-heartbeat.test.ts)
This commit is contained in:
411
packages/engine/src/agent-heartbeat.test.ts
Normal file
411
packages/engine/src/agent-heartbeat.test.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { HeartbeatMonitor, type AgentSession } from "./agent-heartbeat.js";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
|
||||
// Mock store factory
|
||||
function createMockStore(overrides: Partial<AgentStore> = {}): AgentStore {
|
||||
return {
|
||||
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
// Mock session factory
|
||||
function createMockSession(): AgentSession {
|
||||
return {
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("HeartbeatMonitor", () => {
|
||||
let store: AgentStore;
|
||||
let monitor: HeartbeatMonitor;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
monitor = new HeartbeatMonitor({ store });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
monitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("initializes with default options", () => {
|
||||
expect(monitor).toBeDefined();
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom pollIntervalMs", () => {
|
||||
const customMonitor = new HeartbeatMonitor({ store, pollIntervalMs: 5000 });
|
||||
expect(customMonitor).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts custom heartbeatTimeoutMs", () => {
|
||||
const customMonitor = new HeartbeatMonitor({ store, heartbeatTimeoutMs: 120000 });
|
||||
expect(customMonitor).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts callbacks", () => {
|
||||
const onMissed = vi.fn();
|
||||
const onRecovered = vi.fn();
|
||||
const onTerminated = vi.fn();
|
||||
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
onMissed,
|
||||
onRecovered,
|
||||
onTerminated,
|
||||
});
|
||||
|
||||
expect(customMonitor).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("initiates polling interval", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
expect(monitor.isActive()).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is idempotent (multiple calls don't create multiple intervals)", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
monitor.start();
|
||||
monitor.start();
|
||||
|
||||
expect(monitor.isActive()).toBe(true);
|
||||
// Stop should clean up properly
|
||||
monitor.stop();
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
it("clears the polling interval", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
expect(monitor.isActive()).toBe(true);
|
||||
|
||||
monitor.stop();
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is safe to call when not started", () => {
|
||||
expect(() => monitor.stop()).not.toThrow();
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("is safe to call multiple times", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
monitor.stop();
|
||||
monitor.stop();
|
||||
monitor.stop();
|
||||
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActive", () => {
|
||||
it("reflects monitor state (false when not started)", () => {
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects monitor state (true when started)", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
expect(monitor.isActive()).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reflects monitor state (false after stopped)", () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
monitor.start();
|
||||
monitor.stop();
|
||||
expect(monitor.isActive()).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackAgent", () => {
|
||||
it("adds agent to tracked set with correct initial state", () => {
|
||||
const session = createMockSession();
|
||||
const before = Date.now();
|
||||
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
const lastSeen = monitor.getLastSeen("agent-001");
|
||||
|
||||
expect(lastSeen).toBeDefined();
|
||||
expect(lastSeen).toBeGreaterThanOrEqual(before);
|
||||
expect(monitor.getTrackedAgents()).toContain("agent-001");
|
||||
});
|
||||
|
||||
it("records initial heartbeat to store", () => {
|
||||
const session = createMockSession();
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
expect(store.recordHeartbeat).toHaveBeenCalledWith("agent-001", "ok", "run-001");
|
||||
});
|
||||
|
||||
it("can track multiple agents", () => {
|
||||
monitor.trackAgent("agent-001", createMockSession(), "run-001");
|
||||
monitor.trackAgent("agent-002", createMockSession(), "run-002");
|
||||
monitor.trackAgent("agent-003", createMockSession(), "run-003");
|
||||
|
||||
expect(monitor.getTrackedAgents()).toHaveLength(3);
|
||||
expect(monitor.getTrackedAgents()).toContain("agent-001");
|
||||
expect(monitor.getTrackedAgents()).toContain("agent-002");
|
||||
expect(monitor.getTrackedAgents()).toContain("agent-003");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordHeartbeat", () => {
|
||||
it("updates lastSeen timestamp", () => {
|
||||
const session = createMockSession();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
const initialLastSeen = monitor.getLastSeen("agent-001")!;
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
monitor.recordHeartbeat("agent-001");
|
||||
|
||||
const newLastSeen = monitor.getLastSeen("agent-001")!;
|
||||
expect(newLastSeen).toBeGreaterThan(initialLastSeen);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records ok heartbeat to store", () => {
|
||||
const session = createMockSession();
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
monitor.recordHeartbeat("agent-001");
|
||||
|
||||
// Should have been called twice: once on track, once on heartbeat
|
||||
expect(store.recordHeartbeat).toHaveBeenCalledTimes(2);
|
||||
expect(store.recordHeartbeat).toHaveBeenLastCalledWith("agent-001", "ok", "run-001");
|
||||
});
|
||||
|
||||
it("triggers onRecovered callback after missed heartbeat", () => {
|
||||
const onRecovered = vi.fn();
|
||||
const customMonitor = new HeartbeatMonitor({ store, onRecovered });
|
||||
const session = createMockSession();
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
// Simulate missed heartbeat by advancing time
|
||||
vi.advanceTimersByTime(70000); // Default timeout is 60000
|
||||
|
||||
// Trigger the check
|
||||
customMonitor.stop();
|
||||
|
||||
// Reset and record heartbeat (should trigger recovery)
|
||||
customMonitor.recordHeartbeat("agent-001");
|
||||
expect(onRecovered).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does nothing for untracked agent", () => {
|
||||
expect(() => monitor.recordHeartbeat("agent-001")).not.toThrow();
|
||||
expect(store.recordHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAgentHealthy", () => {
|
||||
it("returns true for recent heartbeat", () => {
|
||||
const session = createMockSession();
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
expect(monitor.isAgentHealthy("agent-001")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for missed heartbeat", () => {
|
||||
const session = createMockSession();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
// Use short timeout for testing
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
expect(customMonitor.isAgentHealthy("agent-001")).toBe(true);
|
||||
|
||||
// Advance past timeout
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(customMonitor.isAgentHealthy("agent-001")).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false for untracked agent", () => {
|
||||
expect(monitor.isAgentHealthy("agent-001")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTrackedAgents", () => {
|
||||
it("returns empty array when no agents tracked", () => {
|
||||
expect(monitor.getTrackedAgents()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all tracked agent IDs", () => {
|
||||
monitor.trackAgent("agent-001", createMockSession(), "run-001");
|
||||
monitor.trackAgent("agent-002", createMockSession(), "run-002");
|
||||
|
||||
const agents = monitor.getTrackedAgents();
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents).toContain("agent-001");
|
||||
expect(agents).toContain("agent-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastSeen", () => {
|
||||
it("returns correct timestamp for tracked agent", () => {
|
||||
const session = createMockSession();
|
||||
const before = Date.now();
|
||||
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
const lastSeen = monitor.getLastSeen("agent-001");
|
||||
|
||||
expect(lastSeen).toBeDefined();
|
||||
expect(lastSeen).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("returns undefined for untracked agent", () => {
|
||||
expect(monitor.getLastSeen("agent-001")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missed heartbeat detection", () => {
|
||||
it("triggers onMissed callback when heartbeat is missed", async () => {
|
||||
const onMissed = vi.fn();
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
pollIntervalMs: 1000,
|
||||
onMissed,
|
||||
});
|
||||
const session = createMockSession();
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customMonitor.start();
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
// Wait for polling to detect missed heartbeat
|
||||
vi.advanceTimersByTime(6000);
|
||||
|
||||
// Wait for async checkMissedHeartbeats
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(onMissed).toHaveBeenCalledWith("agent-001");
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records missed heartbeat to store", async () => {
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
pollIntervalMs: 1000,
|
||||
});
|
||||
const session = createMockSession();
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customMonitor.start();
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
// Wait for polling to detect missed heartbeat
|
||||
vi.advanceTimersByTime(6000);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(store.recordHeartbeat).toHaveBeenCalledWith("agent-001", "missed", "run-001");
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresponsive agent termination", () => {
|
||||
it("disposes session and terminates agent after 2x timeout", async () => {
|
||||
const onTerminated = vi.fn();
|
||||
const session = createMockSession();
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
pollIntervalMs: 1000,
|
||||
onTerminated,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customMonitor.start();
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
// Wait for missed heartbeat (1x timeout)
|
||||
vi.advanceTimersByTime(6000);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Wait for termination (2x timeout = 10 seconds total from start)
|
||||
vi.advanceTimersByTime(6000);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(session.dispose).toHaveBeenCalled();
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(onTerminated).toHaveBeenCalledWith("agent-001");
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("removes agent from tracking after termination", async () => {
|
||||
const session = createMockSession();
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
pollIntervalMs: 1000,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customMonitor.start();
|
||||
customMonitor.trackAgent("agent-001", session, "run-001");
|
||||
|
||||
expect(customMonitor.getTrackedAgents()).toContain("agent-001");
|
||||
|
||||
// Wait for termination
|
||||
vi.advanceTimersByTime(12000);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(customMonitor.getTrackedAgents()).not.toContain("agent-001");
|
||||
|
||||
customMonitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("untrackAgent", () => {
|
||||
it("removes agent from tracking", () => {
|
||||
const session = createMockSession();
|
||||
monitor.trackAgent("agent-001", session, "run-001");
|
||||
expect(monitor.getTrackedAgents()).toContain("agent-001");
|
||||
|
||||
monitor.untrackAgent("agent-001");
|
||||
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
|
||||
expect(monitor.getTrackedAgents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is safe to call for untracked agent", () => {
|
||||
expect(() => monitor.untrackAgent("agent-001")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user