Files
fusion/packages/dashboard/app/hooks/__tests__/useAgents.test.ts
Fusion 3026bccc81 feat(FN-3550): add pending approval badges to agent views and header
Added pending-approval badge indicators across the agent detail view, agents overview, mailbox, header, and mobile nav, wired through new and existing agent/mailbox APIs, with test coverage and documentation updates.

Fusion-Task-Id: FN-3550
2026-05-09 10:31:05 -07:00

227 lines
7.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useAgents } from "../useAgents";
import * as api from "../../api";
import type { Agent, AgentCapability, AgentState, AgentStats } from "../../api";
import { MockEventSource } from "../../../vitest.setup";
vi.mock("../../api", () => ({
fetchAgents: vi.fn(),
fetchAgentStats: vi.fn(),
}));
const mockFetchAgents = vi.mocked(api.fetchAgents);
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
function expectEventsUrl(url: string, projectId?: string) {
const parsed = new URL(url, "http://localhost");
expect(parsed.pathname).toBe("/api/events");
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
expect(parsed.searchParams.get("clientId")).toBeTruthy();
}
function createAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
name: "Agent One",
role: "executor" as AgentCapability,
state: "idle" as AgentState,
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const defaultStats: AgentStats = {
activeCount: 1,
assignedTaskCount: 2,
completedRuns: 10,
failedRuns: 1,
successRate: 0.9,
};
describe("useAgents", () => {
beforeEach(() => {
MockEventSource.instances = [];
window.sessionStorage.clear();
mockFetchAgents.mockReset().mockResolvedValue([]);
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const es of MockEventSource.instances) {
es.close();
}
MockEventSource.instances = [];
});
it("returns empty agents and null stats initially; loading settles after fetch", async () => {
const { result } = renderHook(() => useAgents());
expect(result.current.agents).toEqual([]);
expect(result.current.stats).toBeNull();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockFetchAgents).toHaveBeenCalled();
expect(mockFetchAgentStats).toHaveBeenCalled();
});
it("fetches agents and stats on mount and populates state", async () => {
const agents = [
createAgent({ id: "a-1", name: "Alpha", state: "active" }),
createAgent({ id: "a-2", name: "Beta", state: "idle" }),
];
mockFetchAgents.mockResolvedValueOnce(agents);
mockFetchAgentStats.mockResolvedValueOnce({ ...defaultStats, activeCount: 1 });
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.agents).toEqual(agents);
expect(result.current.stats).toEqual({ ...defaultStats, activeCount: 1 });
});
});
it("filters active agents from mixed states", async () => {
const agents = [
createAgent({ id: "a-idle", state: "idle" }),
createAgent({ id: "a-active", state: "active" }),
createAgent({ id: "a-running", state: "running" }),
createAgent({ id: "a-error", state: "error" }),
];
mockFetchAgents.mockResolvedValueOnce(agents);
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.activeAgents.map((a) => a.id)).toEqual(["a-active", "a-running"]);
});
});
it("loadAgents accepts optional filters and passes them to fetchAgents", async () => {
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
await act(async () => {
await result.current.loadAgents({ state: "active", role: "executor" });
});
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor", includeEphemeral: false }, undefined);
});
it("refreshAgents reloads both list and stats in one call", async () => {
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
expect(mockFetchAgentStats).toHaveBeenCalled();
});
mockFetchAgents.mockClear();
mockFetchAgentStats.mockClear();
await act(async () => {
await result.current.refreshAgents();
});
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
});
it("handles fetchAgents rejection gracefully", async () => {
mockFetchAgents.mockRejectedValueOnce(new Error("agents failed"));
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.agents).toEqual([]);
expect(console.error).toHaveBeenCalledWith("Failed to load agents:", expect.any(Error));
});
it("handles fetchAgentStats rejection gracefully", async () => {
mockFetchAgentStats.mockRejectedValueOnce(new Error("stats failed"));
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(mockFetchAgentStats).toHaveBeenCalled();
});
expect(result.current.stats).toBeNull();
expect(console.error).toHaveBeenCalledWith("Failed to load agent stats:", expect.any(Error));
});
it("creates SSE subscription with correct URL without projectId", async () => {
renderHook(() => useAgents());
await waitFor(() => {
const urls = MockEventSource.instances.map((es) => es.url);
expect(urls.length).toBeGreaterThan(0);
expectEventsUrl(urls[urls.length - 1]!);
});
});
it("refreshes agents and stats on supported SSE events", async () => {
renderHook(() => useAgents());
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0);
});
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
mockFetchAgents.mockClear();
mockFetchAgentStats.mockClear();
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) {
act(() => {
es._emit(event);
});
}
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(7);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(7);
});
});
it("closes SSE subscription on unmount", async () => {
const { unmount } = renderHook(() => useAgents());
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0);
});
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
unmount();
expect(es.close).toHaveBeenCalled();
});
it("with projectId passes projectId to fetch calls and EventSource URL", async () => {
const projectId = "proj-123";
renderHook(() => useAgents(projectId));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ includeEphemeral: false }, projectId);
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
});
const urls = MockEventSource.instances.map((es) => es.url);
expect(urls.length).toBeGreaterThan(0);
expectEventsUrl(urls[urls.length - 1]!, projectId);
});
});