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:
278
packages/core/src/board.test.ts
Normal file
278
packages/core/src/board.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
import { VALID_TRANSITIONS, type Task, type Column } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board logic tests
|
||||||
|
*
|
||||||
|
* Tests for column transition validation and dependency resolution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("board", () => {
|
||||||
|
describe("canTransition", () => {
|
||||||
|
it("returns true for all valid transitions defined in VALID_TRANSITIONS", () => {
|
||||||
|
for (const [from, validTos] of Object.entries(VALID_TRANSITIONS)) {
|
||||||
|
for (const to of validTos) {
|
||||||
|
expect(canTransition(from as Column, to)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid transitions", () => {
|
||||||
|
const allColumns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||||
|
|
||||||
|
for (const from of allColumns) {
|
||||||
|
for (const to of allColumns) {
|
||||||
|
const isValid = VALID_TRANSITIONS[from].includes(to);
|
||||||
|
if (!isValid) {
|
||||||
|
expect(canTransition(from, to)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for some invalid backwards transitions", () => {
|
||||||
|
// done cannot go back to in-review (must go to archived first)
|
||||||
|
expect(canTransition("done", "in-review")).toBe(false);
|
||||||
|
// archived cannot go directly back to in-progress
|
||||||
|
expect(canTransition("archived", "in-progress")).toBe(false);
|
||||||
|
// triage cannot go backwards at all (no transitions before it)
|
||||||
|
expect(canTransition("triage", "done")).toBe(false);
|
||||||
|
expect(canTransition("triage", "archived")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for skipping columns", () => {
|
||||||
|
// triage cannot skip to in-progress
|
||||||
|
expect(canTransition("triage", "in-progress")).toBe(false);
|
||||||
|
// todo cannot skip to in-review
|
||||||
|
expect(canTransition("todo", "in-review")).toBe(false);
|
||||||
|
// in-progress cannot skip to done
|
||||||
|
expect(canTransition("in-progress", "done")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getValidTransitions", () => {
|
||||||
|
it("returns correct arrays for each column", () => {
|
||||||
|
for (const [column, expected] of Object.entries(VALID_TRANSITIONS)) {
|
||||||
|
expect(getValidTransitions(column as Column)).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a copy of the array (modifications don't affect original)", () => {
|
||||||
|
const transitions = getValidTransitions("todo");
|
||||||
|
transitions.push("archived" as Column);
|
||||||
|
|
||||||
|
// Original should be unchanged
|
||||||
|
expect(getValidTransitions("todo")).not.toContain("archived");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for triage", () => {
|
||||||
|
expect(getValidTransitions("triage")).toEqual(["todo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for todo", () => {
|
||||||
|
expect(getValidTransitions("todo")).toEqual(["in-progress", "triage"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for in-progress", () => {
|
||||||
|
expect(getValidTransitions("in-progress")).toEqual(["in-review", "todo", "triage"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for in-review", () => {
|
||||||
|
expect(getValidTransitions("in-review")).toEqual(["done", "in-progress"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for done", () => {
|
||||||
|
expect(getValidTransitions("done")).toEqual(["archived"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct transitions for archived", () => {
|
||||||
|
expect(getValidTransitions("archived")).toEqual(["done"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveDependencyOrder", () => {
|
||||||
|
function createTask(id: string, dependencies: string[] = []): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
description: `Task ${id}`,
|
||||||
|
column: "todo",
|
||||||
|
dependencies,
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns empty array for empty task array", () => {
|
||||||
|
expect(resolveDependencyOrder([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single task ID when no dependencies", () => {
|
||||||
|
const task = createTask("KB-001");
|
||||||
|
expect(resolveDependencyOrder([task])).toEqual(["KB-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles linear dependencies (A → B → C)", () => {
|
||||||
|
// C depends on B, B depends on A
|
||||||
|
const taskC = createTask("KB-003", ["KB-002"]);
|
||||||
|
const taskB = createTask("KB-002", ["KB-001"]);
|
||||||
|
const taskA = createTask("KB-001");
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskC, taskB, taskA]);
|
||||||
|
|
||||||
|
// A should come before B, B should come before C
|
||||||
|
const indexA = order.indexOf("KB-001");
|
||||||
|
const indexB = order.indexOf("KB-002");
|
||||||
|
const indexC = order.indexOf("KB-003");
|
||||||
|
|
||||||
|
expect(indexA).toBeLessThan(indexB);
|
||||||
|
expect(indexB).toBeLessThan(indexC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles diamond dependencies (A → B, A → C, B → D, C → D)", () => {
|
||||||
|
// A
|
||||||
|
// / \
|
||||||
|
// B C
|
||||||
|
// \ /
|
||||||
|
// D
|
||||||
|
const taskA = createTask("KB-A");
|
||||||
|
const taskB = createTask("KB-B", ["KB-A"]);
|
||||||
|
const taskC = createTask("KB-C", ["KB-A"]);
|
||||||
|
const taskD = createTask("KB-D", ["KB-B", "KB-C"]);
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskD, taskC, taskB, taskA]);
|
||||||
|
|
||||||
|
const indexA = order.indexOf("KB-A");
|
||||||
|
const indexB = order.indexOf("KB-B");
|
||||||
|
const indexC = order.indexOf("KB-C");
|
||||||
|
const indexD = order.indexOf("KB-D");
|
||||||
|
|
||||||
|
// A should be first
|
||||||
|
expect(indexA).toBeLessThan(indexB);
|
||||||
|
expect(indexA).toBeLessThan(indexC);
|
||||||
|
// Both B and C should come before D
|
||||||
|
expect(indexB).toBeLessThan(indexD);
|
||||||
|
expect(indexC).toBeLessThan(indexD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles disconnected components (independent tasks)", () => {
|
||||||
|
const taskA = createTask("KB-A");
|
||||||
|
const taskB = createTask("KB-B");
|
||||||
|
const taskC = createTask("KB-C");
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskB, taskC, taskA]);
|
||||||
|
|
||||||
|
// All tasks should be in the output
|
||||||
|
expect(order).toContain("KB-A");
|
||||||
|
expect(order).toContain("KB-B");
|
||||||
|
expect(order).toContain("KB-C");
|
||||||
|
expect(order).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles circular dependencies gracefully (should not infinite loop)", () => {
|
||||||
|
// A → B → C → A (circular)
|
||||||
|
const taskA = createTask("KB-A", ["KB-C"]);
|
||||||
|
const taskB = createTask("KB-B", ["KB-A"]);
|
||||||
|
const taskC = createTask("KB-C", ["KB-B"]);
|
||||||
|
|
||||||
|
// Should complete without hanging
|
||||||
|
const order = resolveDependencyOrder([taskA, taskB, taskC]);
|
||||||
|
|
||||||
|
// All tasks should be in the output (order is not strictly defined for circular)
|
||||||
|
expect(order).toContain("KB-A");
|
||||||
|
expect(order).toContain("KB-B");
|
||||||
|
expect(order).toContain("KB-C");
|
||||||
|
expect(order).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles self-referential dependencies gracefully", () => {
|
||||||
|
const taskA = createTask("KB-A", ["KB-A"]);
|
||||||
|
const taskB = createTask("KB-B");
|
||||||
|
|
||||||
|
// Should complete without infinite recursion
|
||||||
|
const order = resolveDependencyOrder([taskA, taskB]);
|
||||||
|
|
||||||
|
expect(order).toContain("KB-A");
|
||||||
|
expect(order).toContain("KB-B");
|
||||||
|
expect(order).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial ordering correctly", () => {
|
||||||
|
// A depends on B, C and D are independent
|
||||||
|
const taskA = createTask("KB-A", ["KB-B"]);
|
||||||
|
const taskB = createTask("KB-B");
|
||||||
|
const taskC = createTask("KB-C");
|
||||||
|
const taskD = createTask("KB-D");
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskA, taskB, taskC, taskD]);
|
||||||
|
|
||||||
|
// B must come before A
|
||||||
|
expect(order.indexOf("KB-B")).toBeLessThan(order.indexOf("KB-A"));
|
||||||
|
|
||||||
|
// All tasks should be present
|
||||||
|
expect(order).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty dependencies array correctly", () => {
|
||||||
|
const taskA = createTask("KB-A", []);
|
||||||
|
const taskB = createTask("KB-B", []);
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskA, taskB]);
|
||||||
|
|
||||||
|
expect(order).toContain("KB-A");
|
||||||
|
expect(order).toContain("KB-B");
|
||||||
|
expect(order).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles complex dependency graph", () => {
|
||||||
|
// E depends on D
|
||||||
|
// D depends on B and C
|
||||||
|
// B depends on A
|
||||||
|
// C depends on A
|
||||||
|
// A has no deps
|
||||||
|
const taskA = createTask("KB-A");
|
||||||
|
const taskB = createTask("KB-B", ["KB-A"]);
|
||||||
|
const taskC = createTask("KB-C", ["KB-A"]);
|
||||||
|
const taskD = createTask("KB-D", ["KB-B", "KB-C"]);
|
||||||
|
const taskE = createTask("KB-E", ["KB-D"]);
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder([taskE, taskD, taskC, taskB, taskA]);
|
||||||
|
|
||||||
|
// Validate partial ordering constraints
|
||||||
|
expect(order.indexOf("KB-A")).toBeLessThan(order.indexOf("KB-B"));
|
||||||
|
expect(order.indexOf("KB-A")).toBeLessThan(order.indexOf("KB-C"));
|
||||||
|
expect(order.indexOf("KB-B")).toBeLessThan(order.indexOf("KB-D"));
|
||||||
|
expect(order.indexOf("KB-C")).toBeLessThan(order.indexOf("KB-D"));
|
||||||
|
expect(order.indexOf("KB-D")).toBeLessThan(order.indexOf("KB-E"));
|
||||||
|
|
||||||
|
expect(order).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all tasks from input (no tasks dropped)", () => {
|
||||||
|
const tasks = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
createTask(`KB-${String(i + 1).padStart(3, "0")}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = resolveDependencyOrder(tasks);
|
||||||
|
|
||||||
|
expect(order).toHaveLength(10);
|
||||||
|
for (const task of tasks) {
|
||||||
|
expect(order).toContain(task.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns deterministic order for same input", () => {
|
||||||
|
const taskA = createTask("KB-A");
|
||||||
|
const taskB = createTask("KB-B", ["KB-A"]);
|
||||||
|
const taskC = createTask("KB-C", ["KB-A"]);
|
||||||
|
|
||||||
|
const order1 = resolveDependencyOrder([taskA, taskB, taskC]);
|
||||||
|
const order2 = resolveDependencyOrder([taskA, taskB, taskC]);
|
||||||
|
|
||||||
|
expect(order1).toEqual(order2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
367
packages/dashboard/app/hooks/useToast.test.tsx
Normal file
367
packages/dashboard/app/hooks/useToast.test.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, renderHook, act, screen } from "@testing-library/react";
|
||||||
|
import { ToastProvider, useToast } from "./useToast";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast hook tests
|
||||||
|
*
|
||||||
|
* Tests for the Toast context provider and useToast hook.
|
||||||
|
* Note: The current implementation does not clean up setTimeout timers
|
||||||
|
* when the provider unmounts, which could lead to memory leaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <ToastProvider>{children}</ToastProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ToastProvider", () => {
|
||||||
|
it("renders children correctly", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToastProvider>
|
||||||
|
<div data-testid="child">Test Child</div>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("child")).toBeDefined();
|
||||||
|
expect(container.textContent).toContain("Test Child");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useToast", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when used outside provider", () => {
|
||||||
|
// Suppress console.error for expected error
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useToast());
|
||||||
|
}).toThrow("useToast must be used within ToastProvider");
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct context value within provider", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveProperty("toasts");
|
||||||
|
expect(result.current).toHaveProperty("addToast");
|
||||||
|
expect(result.current).toHaveProperty("removeToast");
|
||||||
|
expect(typeof result.current.addToast).toBe("function");
|
||||||
|
expect(typeof result.current.removeToast).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("`toasts` array is initially empty", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addToast", () => {
|
||||||
|
it("adds a toast to the list with correct message and type", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test message", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
expect(result.current.toasts[0].message).toBe("Test message");
|
||||||
|
expect(result.current.toasts[0].type).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-assigns unique incrementing IDs starting from 0", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("First", "info");
|
||||||
|
result.current.addToast("Second", "info");
|
||||||
|
result.current.addToast("Third", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts[0].id).toBe(0);
|
||||||
|
expect(result.current.toasts[1].id).toBe(1);
|
||||||
|
expect(result.current.toasts[2].id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults type to 'info' when not specified", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test message");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts[0].type).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 'success' type", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Success!", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts[0].type).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 'error' type", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Error!", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts[0].type).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 'info' type explicitly", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Info", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts[0].type).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts auto-remove after 4000ms", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test message");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
|
||||||
|
// Advance time by 4000ms
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts remain visible before 4000ms expires", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test message");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance time by 3999ms - toast should still be there
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3999);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeToast", () => {
|
||||||
|
it("manually removes a specific toast by ID", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("First", "info");
|
||||||
|
result.current.addToast("Second", "info");
|
||||||
|
result.current.addToast("Third", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(3);
|
||||||
|
|
||||||
|
// Remove the second toast (ID: 1)
|
||||||
|
act(() => {
|
||||||
|
result.current.removeToast(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(2);
|
||||||
|
expect(result.current.toasts.map((t) => t.id)).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when removing non-existent toast ID", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
|
||||||
|
// Try to remove non-existent ID
|
||||||
|
act(() => {
|
||||||
|
result.current.removeToast(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast should still be there
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple toasts", () => {
|
||||||
|
it("can exist simultaneously", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("First", "info");
|
||||||
|
result.current.addToast("Second", "success");
|
||||||
|
result.current.addToast("Third", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(3);
|
||||||
|
expect(result.current.toasts[0].message).toBe("First");
|
||||||
|
expect(result.current.toasts[1].message).toBe("Second");
|
||||||
|
expect(result.current.toasts[2].message).toBe("Third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-remove independently based on their creation time", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add first toast at time 0
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("First", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance 2000ms and add second toast
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Second", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(2);
|
||||||
|
|
||||||
|
// Advance 2000ms more (total 4000ms from first toast)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// First toast should be gone, second still there
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
expect(result.current.toasts[0].message).toBe("Second");
|
||||||
|
|
||||||
|
// Advance 2000ms more (total 4000ms from second toast)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both should be gone
|
||||||
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("have unique IDs even with same message", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Same message", "info");
|
||||||
|
result.current.addToast("Same message", "info");
|
||||||
|
result.current.addToast("Same message", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = result.current.toasts.map((t) => t.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
|
||||||
|
expect(uniqueIds.size).toBe(3); // All IDs should be unique
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("timer cleanup on unmount (future improvement)", () => {
|
||||||
|
// Note: The current implementation does not clean up setTimeout timers
|
||||||
|
// when the provider unmounts. This is a known limitation that could lead
|
||||||
|
// to memory leaks and React warnings about state updates on unmounted
|
||||||
|
// components. The following test documents the expected behavior if this
|
||||||
|
// is ever fixed in the future.
|
||||||
|
|
||||||
|
it.skip("should clear pending timers when provider unmounts (not currently implemented)", () => {
|
||||||
|
// This test is skipped because the current implementation does not
|
||||||
|
// store timer references for cleanup. To implement this properly,
|
||||||
|
// the addToast function would need to:
|
||||||
|
// 1. Store setTimeout handle in a ref
|
||||||
|
// 2. Return cleanup function or use useEffect cleanup
|
||||||
|
|
||||||
|
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// This expectation would fail with current implementation
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
clearTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues auto-removing toasts after one is removed early", () => {
|
||||||
|
const { result } = renderHook(() => useToast(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addToast("First", "info");
|
||||||
|
result.current.addToast("Second", "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove first toast immediately
|
||||||
|
act(() => {
|
||||||
|
result.current.removeToast(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.toasts).toHaveLength(1);
|
||||||
|
expect(result.current.toasts[0].message).toBe("Second");
|
||||||
|
|
||||||
|
// Advance full 4000ms from when second toast was created
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second toast should still auto-remove
|
||||||
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
166
packages/dashboard/app/utils/modelFilter.test.ts
Normal file
166
packages/dashboard/app/utils/modelFilter.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { filterModels } from "./modelFilter";
|
||||||
|
import type { ModelInfo } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model filter utility tests
|
||||||
|
*
|
||||||
|
* Tests for filtering AI models by provider, ID, or name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createModel(
|
||||||
|
provider: string,
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
reasoning = false,
|
||||||
|
contextWindow = 128000,
|
||||||
|
): ModelInfo {
|
||||||
|
return { provider, id, name, reasoning, contextWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterModels", () => {
|
||||||
|
const models: ModelInfo[] = [
|
||||||
|
createModel("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5"),
|
||||||
|
createModel("anthropic", "claude-opus-4", "Claude Opus 4", true),
|
||||||
|
createModel("openai", "gpt-4o", "GPT-4o"),
|
||||||
|
createModel("openai", "gpt-4o-mini", "GPT-4o Mini"),
|
||||||
|
createModel("google", "gemini-pro", "Gemini Pro"),
|
||||||
|
createModel("ollama", "llama3.1", "Llama 3.1"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns all models when filter is empty string", () => {
|
||||||
|
expect(filterModels(models, "")).toEqual(models);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all models when filter is whitespace-only", () => {
|
||||||
|
expect(filterModels(models, " ")).toEqual(models);
|
||||||
|
expect(filterModels(models, " \t \n ")).toEqual(models);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider (case-insensitive)", () => {
|
||||||
|
const result = filterModels(models, "anthropic");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
|
||||||
|
expect(result.map((m) => m.id)).toContain("claude-opus-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider (uppercase)", () => {
|
||||||
|
const result = filterModels(models, "ANTHROPIC");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by provider (mixed case)", () => {
|
||||||
|
const result = filterModels(models, "OpenAI");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toContain("gpt-4o");
|
||||||
|
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by model ID (case-insensitive, matches exact ID)", () => {
|
||||||
|
// Using unique ID "opus" that doesn't appear in other models
|
||||||
|
const result = filterModels(models, "opus");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("claude-opus-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by partial model ID (substring matching)", () => {
|
||||||
|
const result = filterModels(models, "claude");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.provider)).toContain("anthropic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by model name (case-insensitive)", () => {
|
||||||
|
const result = filterModels(models, "sonnet");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by model name (partial match)", () => {
|
||||||
|
// "opus" appears in "Claude Opus 4" name
|
||||||
|
const result = filterModels(models, "opus");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("claude-opus-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multi-word filters with AND logic", () => {
|
||||||
|
// "anthropic" AND "sonnet" should match only Claude Sonnet
|
||||||
|
const result = filterModels(models, "anthropic sonnet");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multi-word filters with multiple matches", () => {
|
||||||
|
// "gpt" should match both gpt-4o and gpt-4o-mini
|
||||||
|
const result = filterModels(models, "gpt 4o");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial matches across multiple fields", () => {
|
||||||
|
// "pro" matches "Gemini Pro" in name
|
||||||
|
const result = filterModels(models, "pro");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("gemini-pro");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no matches", () => {
|
||||||
|
const result = filterModels(models, "nonexistent");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for non-matching multi-word filter", () => {
|
||||||
|
// "anthropic" AND "nonexistent" should match nothing
|
||||||
|
const result = filterModels(models, "anthropic nonexistent");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty model array", () => {
|
||||||
|
expect(filterModels([], "")).toEqual([]);
|
||||||
|
expect(filterModels([], "test")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single model array", () => {
|
||||||
|
const singleModel = [models[0]];
|
||||||
|
expect(filterModels(singleModel, "")).toEqual(singleModel);
|
||||||
|
expect(filterModels(singleModel, "anthropic")).toEqual(singleModel);
|
||||||
|
expect(filterModels(singleModel, "openai")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive across all fields", () => {
|
||||||
|
// Mix of cases should all work
|
||||||
|
expect(filterModels(models, "CLAUDE")).toHaveLength(2);
|
||||||
|
expect(filterModels(models, "GPT-4O")).toHaveLength(2);
|
||||||
|
expect(filterModels(models, "GEMINI")).toHaveLength(1);
|
||||||
|
expect(filterModels(models, "OPUS")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches model ID with special characters", () => {
|
||||||
|
const modelsWithSpecial = [
|
||||||
|
createModel("anthropic", "claude-3.5-sonnet", "Claude 3.5 Sonnet"),
|
||||||
|
createModel("openai", "gpt-4-turbo-preview", "GPT-4 Turbo"),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(filterModels(modelsWithSpecial, "3.5")).toHaveLength(1);
|
||||||
|
expect(filterModels(modelsWithSpecial, "turbo-preview")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles leading and trailing whitespace in filter", () => {
|
||||||
|
const result = filterModels(models, " anthropic ");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple spaces between terms", () => {
|
||||||
|
const result = filterModels(models, "anthropic sonnet");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("claude-sonnet-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches substring anywhere in provider, id, or name", () => {
|
||||||
|
// "ai" appears in "openai" provider
|
||||||
|
const result = filterModels(models, "ai");
|
||||||
|
expect(result.map((m) => m.provider)).toContain("openai");
|
||||||
|
|
||||||
|
// "ll" appears in "ollama" provider and "llama" id
|
||||||
|
const resultLl = filterModels(models, "ll");
|
||||||
|
expect(resultLl.map((m) => m.id)).toContain("llama3.1");
|
||||||
|
});
|
||||||
|
});
|
||||||
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