import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { AgentsView } from "../AgentsView"; import * as apiModule from "../../api"; import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api"; import { scopedKey } from "../../utils/projectStorage"; // Mock the API module vi.mock("../../api", () => ({ fetchAgents: vi.fn(), fetchAgentStats: vi.fn(), createAgent: vi.fn(), updateAgent: vi.fn(), updateAgentState: vi.fn(), deleteAgent: vi.fn(), startAgentRun: vi.fn(), fetchOrgTree: vi.fn(), fetchModels: vi.fn().mockResolvedValue({ models: [] }), })); vi.mock("../AgentDetailView", () => ({ AgentDetailView: ({ agentId }: { agentId: string }) =>
Agent detail: {agentId}
, })); const mockFetchAgents = vi.mocked(apiModule.fetchAgents); const mockCreateAgent = vi.mocked(apiModule.createAgent); const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState); const mockDeleteAgent = vi.mocked(apiModule.deleteAgent); const mockStartAgentRun = vi.mocked(apiModule.startAgentRun); const mockFetchOrgTree = vi.mocked((apiModule as any).fetchOrgTree); const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats); describe("AgentsView", () => { const mockAddToast = vi.fn(); const projectId = "proj_123"; const mockAgents: Agent[] = [ { id: "agent-001", name: "Test Agent 1", role: "executor" as AgentCapability, state: "idle" as AgentState, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, { id: "agent-002", name: "Test Agent 2", role: "triage" as AgentCapability, state: "active" as AgentState, taskId: "FN-001", lastHeartbeatAt: new Date().toISOString(), createdAt: new Date(Date.now() - 86400000).toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, { id: "agent-003", name: "Test Agent 3", role: "custom" as AgentCapability, state: "paused" as AgentState, createdAt: new Date(Date.now() - 172800000).toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, { id: "agent-004", name: "Test Agent 4", role: "reviewer" as AgentCapability, state: "terminated" as AgentState, createdAt: new Date(Date.now() - 259200000).toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, ]; beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockFetchAgents.mockResolvedValue(mockAgents); mockFetchAgentStats.mockResolvedValue({ total: 4, byState: {}, byRole: {} }); mockCreateAgent.mockResolvedValue(mockAgents[0]); mockUpdateAgentState.mockResolvedValue({ ...mockAgents[0], state: "active" }); mockDeleteAgent.mockResolvedValue(undefined); mockStartAgentRun.mockResolvedValue({ id: "run-001", agentId: "agent-001", startedAt: new Date().toISOString(), endedAt: null, status: "active", }); mockFetchOrgTree.mockResolvedValue([]); }); describe("rendering", () => { it("renders the agents view header", async () => { render(); await waitFor(() => { expect(screen.getByText("Agents")).toBeTruthy(); }); }); it("renders agent list on mount", async () => { render(); await waitFor(() => { // Active agents may appear in both ActiveAgentsPanel and main list expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Test Agent 2").length).toBeGreaterThanOrEqual(1); }); }); it("fetches agents on mount", async () => { render(); await waitFor(() => { expect(mockFetchAgents).toHaveBeenCalled(); }); }); it("passes projectId to agent fetches", async () => { render(); await waitFor(() => { expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId); }); }); it("renders empty state when no agents", async () => { mockFetchAgents.mockResolvedValue([]); render(); await waitFor(() => { expect(screen.getByText("No agents found")).toBeTruthy(); expect(screen.getByText("Create an agent to get started")).toBeTruthy(); }); }); it("displays agent states", async () => { render(); await waitFor(() => { expect(screen.getAllByText("idle").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("active").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("paused").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("terminated").length).toBeGreaterThanOrEqual(1); }); }); it("displays agent task when working on one", async () => { render(); await waitFor(() => { expect(screen.getAllByText("FN-001").length).toBeGreaterThanOrEqual(1); }); }); it("shows refresh button", async () => { render(); const refreshBtn = screen.getByTitle("Refresh"); expect(refreshBtn).toBeTruthy(); }); }); describe("view toggle (list/board)", () => { it("can toggle between list and board view", async () => { render(); await waitFor(() => { expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThanOrEqual(1); }); // Initially should show list view (default) expect(document.querySelector(".agent-list")).toBeTruthy(); // Switch to board view fireEvent.click(screen.getByTitle("Board view")); await waitFor(() => { expect(document.querySelector(".agent-board")).toBeTruthy(); }); // Switch back to list view fireEvent.click(screen.getByTitle("List view")); await waitFor(() => { expect(document.querySelector(".agent-list")).toBeTruthy(); }); }); it("board view shows compact cards", async () => { render(); await waitFor(() => { expect(screen.getByText("Agents")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Board view")); await waitFor(() => { const boardCards = document.querySelectorAll(".agent-board-card"); expect(boardCards.length).toBe(mockAgents.length); }); }); it("persists view toggle preference to project-scoped localStorage", async () => { render(); await waitFor(() => { expect(screen.getByText("Agents")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Board view")); await waitFor(() => { expect(localStorage.getItem(scopedKey("kb-agent-view", projectId))).toBe("board"); }); }); it("defaults to list view when no localStorage preference exists", async () => { render(); await waitFor(() => { const listContainer = document.querySelector(".agent-list"); expect(listContainer).toBeTruthy(); }); }); it("marks board view button as active when in board mode", async () => { render(); await waitFor(() => { expect(screen.getByText("Agents")).toBeTruthy(); }); const boardBtn = screen.getByTitle("Board view"); fireEvent.click(boardBtn); await waitFor(() => { expect(boardBtn.className).toContain("active"); expect(boardBtn.getAttribute("aria-pressed")).toBe("true"); }); }); }); describe("Org Chart view", () => { const orgTree: OrgTreeNode[] = [ { agent: { id: "agent-root-1", name: "Chief Agent", role: "scheduler", state: "active", lastHeartbeatAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [ { agent: { id: "agent-child-1", name: "Director One", role: "executor", state: "running", lastHeartbeatAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [ { agent: { id: "agent-grandchild-1", name: "Manager Alpha", role: "reviewer", state: "idle", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [], }, ], }, { agent: { id: "agent-child-2", name: "Director Two", role: "triage", state: "paused", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [], }, ], }, { agent: { id: "agent-root-2", name: "Independent Lead", role: "engineer", state: "error", lastError: "Agent stalled", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [], }, ]; it("renders org chart toggle with aria attributes and activates org view", async () => { mockFetchOrgTree.mockResolvedValue(orgTree); render(); const orgButton = screen.getByRole("button", { name: "Org Chart view" }); expect(orgButton.getAttribute("aria-pressed")).toBe("false"); fireEvent.click(orgButton); await waitFor(() => { expect(orgButton.className).toContain("active"); expect(orgButton.getAttribute("aria-pressed")).toBe("true"); }); await waitFor(() => { expect(mockFetchOrgTree).toHaveBeenCalledWith(projectId); }); }); it("renders org chart nodes and opens detail view when clicking a node", async () => { mockFetchOrgTree.mockResolvedValue(orgTree); render(); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); await waitFor(() => { expect(screen.getByText("Chief Agent")).toBeTruthy(); expect(screen.getByText("Director One")).toBeTruthy(); expect(screen.getByText("Manager Alpha")).toBeTruthy(); expect(screen.getByText("Independent Lead")).toBeTruthy(); expect(screen.getAllByText(/Healthy|Idle|Paused|Unresponsive|Agent stalled/).length).toBeGreaterThan(0); }); fireEvent.click(screen.getByText("Director One")); await waitFor(() => { expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1"); }); }); it("shows org chart empty state when API returns no nodes", async () => { mockFetchOrgTree.mockResolvedValue([]); render(); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); await waitFor(() => { expect(screen.getByText("No agents found")).toBeTruthy(); expect(screen.getByText("Create an agent to get started")).toBeTruthy(); }); }); it("shows loading state while org chart request is in flight", async () => { let resolveOrgTree: ((value: OrgTreeNode[]) => void) | undefined; mockFetchOrgTree.mockImplementation( () => new Promise((resolve) => { resolveOrgTree = resolve; }), ); render(); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); await waitFor(() => { expect(screen.getByText("Loading org chart...")).toBeTruthy(); }); resolveOrgTree?.([]); await waitFor(() => { expect(screen.queryByText("Loading org chart...")).toBeNull(); }); }); }); describe("filter agents by state", () => { it("renders the state filter with styled container", async () => { render(); await waitFor(() => { expect(screen.getByText("All States")).toBeTruthy(); }); // Styled filter container exists const filterContainer = document.querySelector(".agent-state-filter"); expect(filterContainer).toBeTruthy(); // Select has correct aria-label const filterSelect = screen.getByLabelText("Filter agents by state"); expect(filterSelect).toBeTruthy(); }); it("can filter agents by state", async () => { render(); await waitFor(() => { expect(screen.getByText("All States")).toBeTruthy(); }); const filterSelect = screen.getByDisplayValue("All States"); fireEvent.change(filterSelect, { target: { value: "active" } }); await waitFor(() => { expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" }, undefined); }); }); it("clears filter when selecting 'all'", async () => { render(); await waitFor(() => { expect(screen.getByText("All States")).toBeTruthy(); }); const filterSelect = screen.getByDisplayValue("All States"); fireEvent.change(filterSelect, { target: { value: "idle" } }); await waitFor(() => { expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" }, undefined); }); fireEvent.change(filterSelect, { target: { value: "all" } }); await waitFor(() => { expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined, undefined); }); }); }); describe("create new agent", () => { it("can create new agent via multi-step dialog", async () => { render(); await waitFor(() => { expect(screen.getByText("New Agent")).toBeTruthy(); }); // Open create dialog fireEvent.click(screen.getByText("New Agent")); // Step 0: Fill in agent name const nameInput = screen.getByPlaceholderText("e.g. Frontend Reviewer"); fireEvent.change(nameInput, { target: { value: "My Agent" } }); // Click Next to step 1 fireEvent.click(screen.getByText("Next")); // Step 1: Model selection - click Next fireEvent.click(screen.getByText("Next")); // Step 2: Review - click Create fireEvent.click(screen.getByText("Create")); await waitFor(() => { expect(mockCreateAgent).toHaveBeenCalledWith( expect.objectContaining({ name: "My Agent", role: "custom", }), undefined, ); }); }); it("shows create dialog when clicking New Agent button", async () => { render(); await waitFor(() => { expect(screen.getByText("New Agent")).toBeTruthy(); }); fireEvent.click(screen.getByText("New Agent")); expect(screen.getByPlaceholderText("e.g. Frontend Reviewer")).toBeTruthy(); }); it("does not allow proceeding with empty name", async () => { render(); await waitFor(() => { expect(screen.getByText("New Agent")).toBeTruthy(); }); fireEvent.click(screen.getByText("New Agent")); // Next button should be disabled when name is empty const nextBtn = screen.getByText("Next"); expect(nextBtn.hasAttribute("disabled")).toBe(true); }); it("handles creation error gracefully", async () => { mockCreateAgent.mockRejectedValue(new Error("Creation failed")); render(); await waitFor(() => { expect(screen.getByText("New Agent")).toBeTruthy(); }); fireEvent.click(screen.getByText("New Agent")); const nameInput = screen.getByPlaceholderText("e.g. Frontend Reviewer"); fireEvent.change(nameInput, { target: { value: "Fail Agent" } }); // Navigate through steps fireEvent.click(screen.getByText("Next")); fireEvent.click(screen.getByText("Next")); fireEvent.click(screen.getByText("Create")); await waitFor(() => { // Error should be shown somewhere (dialog or toast) const errorShown = screen.queryByText(/Creation failed/) !== null || document.body.textContent?.includes("Creation failed"); expect(errorShown).toBe(true); }); }); }); describe("change agent state", () => { it("can change agent state - activate idle agent", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Activate")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Activate")); await waitFor(() => { expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined); expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined); }); expect(mockAddToast).toHaveBeenCalledWith( expect.stringContaining("active"), "success" ); }); it("can pause active agent", async () => { render(); await waitFor(() => { const agentCards = document.querySelectorAll(".agent-card"); expect(agentCards.length).toBeGreaterThan(0); }); // Find the active agent card const agentCards = document.querySelectorAll(".agent-card"); let activeCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-002")) { activeCard = card; } }); expect(activeCard).toBeTruthy(); const pauseButton = activeCard?.querySelector('[title="Pause"]') as HTMLElement; expect(pauseButton).toBeTruthy(); fireEvent.click(pauseButton); await waitFor(() => { expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined); }); }); it("can resume paused agent", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Resume")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Resume")); await waitFor(() => { expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active", undefined); expect(mockStartAgentRun).toHaveBeenCalledWith("agent-003", undefined); }); }); it("handles state change error gracefully", async () => { mockUpdateAgentState.mockRejectedValue(new Error("State change failed")); render(); await waitFor(() => { expect(screen.getByTitle("Activate")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Activate")); await waitFor(() => { expect(mockAddToast).toHaveBeenCalledWith( expect.stringContaining("State change failed"), "error" ); }); }); it("shows error toast when startAgentRun fails but still updates state", async () => { mockStartAgentRun.mockRejectedValue(new Error("Run failed")); render(); await waitFor(() => { expect(screen.getByTitle("Activate")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Activate")); await waitFor(() => { expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined); expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined); expect(mockAddToast).toHaveBeenCalledWith( expect.stringContaining("failed to start run"), "error" ); }); }); it("does not start run when pausing agent", async () => { render(); await waitFor(() => { const agentCards = document.querySelectorAll(".agent-card"); expect(agentCards.length).toBeGreaterThan(0); }); // Find the active agent card const agentCards = document.querySelectorAll(".agent-card"); let activeCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-002")) { activeCard = card; } }); const pauseButton = activeCard?.querySelector('[title="Pause"]') as HTMLElement; fireEvent.click(pauseButton); await waitFor(() => { expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined); }); // startAgentRun should NOT be called when pausing expect(mockStartAgentRun).not.toHaveBeenCalled(); }); }); describe("delete agent", () => { it("shows Delete button for idle and terminated agents", async () => { render(); await waitFor(() => { // There should be multiple Delete buttons: one for idle (agent-001) and one for terminated (agent-004) const deleteButtons = screen.getAllByTitle("Delete"); expect(deleteButtons.length).toBeGreaterThanOrEqual(2); }); }); it("does not show Delete button for active or paused agents", async () => { render(); await waitFor(() => { // Find the active agent card (agent-002) const agentCards = document.querySelectorAll(".agent-card"); let activeCard: Element | null = null; let pausedCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-002")) activeCard = card; if (card.textContent?.includes("agent-003")) pausedCard = card; }); // Active and paused agents should not have delete buttons expect(activeCard?.querySelector('[title="Delete"]')).toBeFalsy(); expect(pausedCard?.querySelector('[title="Delete"]')).toBeFalsy(); }); }); it("confirms before deleting agent", async () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); render(); await waitFor(() => { // Click the delete button for the terminated agent (agent-004) const agentCards = document.querySelectorAll(".agent-card"); let terminatedCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-004")) terminatedCard = card; }); const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement; expect(terminatedDeleteBtn).toBeTruthy(); fireEvent.click(terminatedDeleteBtn); }); expect(confirmSpy).toHaveBeenCalledWith( expect.stringContaining("Test Agent 4") ); expect(mockDeleteAgent).not.toHaveBeenCalled(); confirmSpy.mockRestore(); }); it("deletes agent after confirmation", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); render(); await waitFor(() => { expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2); }); // Find the delete button for terminated agent (agent-004) const agentCards = document.querySelectorAll(".agent-card"); let terminatedCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-004")) terminatedCard = card; }); const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement; fireEvent.click(terminatedDeleteBtn); await waitFor(() => { expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined); }); expect(mockAddToast).toHaveBeenCalledWith( expect.stringContaining("deleted"), "success" ); }); it("deletes idle agent after confirmation", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); render(); await waitFor(() => { expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2); }); // Find the delete button for idle agent (agent-001) const agentCards = document.querySelectorAll(".agent-card"); let idleCard: Element | null = null; agentCards.forEach(card => { if (card.textContent?.includes("agent-001")) idleCard = card; }); const idleDeleteBtn = idleCard?.querySelector('[title="Delete"]') as HTMLElement; fireEvent.click(idleDeleteBtn); await waitFor(() => { expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", undefined); }); expect(mockAddToast).toHaveBeenCalledWith( expect.stringContaining("deleted"), "success" ); }); }); describe("refresh functionality", () => { it("refreshes agent list when clicking refresh button", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Refresh")).toBeTruthy(); }); mockFetchAgents.mockClear(); fireEvent.click(screen.getByTitle("Refresh")); await waitFor(() => { expect(mockFetchAgents).toHaveBeenCalled(); }); }); }); });