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 } from "../../api"; // 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(), fetchModels: vi.fn().mockResolvedValue({ models: [] }), })); 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 mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats); describe("AgentsView", () => { const mockAddToast = vi.fn(); 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); }); 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, "proj_123"); }); }); 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 localStorage", async () => { render(); await waitFor(() => { expect(screen.getByText("Agents")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("Board view")); await waitFor(() => { expect(localStorage.getItem("kb-agent-view")).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("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(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); }); }); 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" ); }); }); }); 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(); }); }); }); });