fix(KB-605): add CSS variables for agent badges and AgentListModal tests

- Add missing CSS variables for agent state badges in AgentListModal
- Add comprehensive test suite for AgentListModal component (754 lines)
- Include changeset documenting the CSS fix
This commit is contained in:
gsxdsm
2026-03-31 17:17:19 -07:00
parent f4dad9197f
commit 965783db95
3 changed files with 778 additions and 0 deletions

View File

@@ -0,0 +1,754 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AgentListModal } from "../AgentListModal";
import * as apiModule from "../../api";
import type { Agent, AgentState, AgentCapability } from "../../api";
// Mock the API module
vi.mock("../../api", () => ({
fetchAgents: vi.fn(),
createAgent: vi.fn(),
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
}));
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
const mockCreateAgent = vi.mocked(apiModule.createAgent);
const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState);
const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
describe("AgentListModal", () => {
const mockOnClose = vi.fn();
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: "KB-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();
mockFetchAgents.mockResolvedValue(mockAgents);
mockCreateAgent.mockResolvedValue(mockAgents[0]);
mockUpdateAgentState.mockResolvedValue({ ...mockAgents[0], state: "active" });
mockDeleteAgent.mockResolvedValue(undefined);
});
describe("modal visibility", () => {
it("renders when isOpen is true", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
});
it("does not render when isOpen is false", () => {
const { container } = render(
<AgentListModal
isOpen={false}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
expect(container.firstChild).toBeNull();
});
it("calls onClose when clicking the overlay", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
fireEvent.click(overlay);
expect(mockOnClose).toHaveBeenCalled();
}
});
it("calls onClose when clicking the close button", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
const closeButton = screen.getByTitle("Close");
expect(closeButton).toBeTruthy();
});
const closeButton = screen.getByTitle("Close");
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});
describe("agent list display", () => {
it("fetches agents on mount", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
it("displays agent names", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Test Agent 1")).toBeTruthy();
expect(screen.getByText("Test Agent 2")).toBeTruthy();
});
});
it("displays agent states as badges", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("idle")).toBeTruthy();
expect(screen.getByText("active")).toBeTruthy();
expect(screen.getByText("paused")).toBeTruthy();
expect(screen.getByText("terminated")).toBeTruthy();
});
});
it("displays agent roles", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Executor")).toBeTruthy();
expect(screen.getByText("Triage")).toBeTruthy();
});
});
it("displays task ID when agent is working on a task", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("KB-001")).toBeTruthy();
});
});
it("shows empty state when no agents exist", async () => {
mockFetchAgents.mockResolvedValue([]);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("No agents found")).toBeTruthy();
expect(screen.getByText("Create an agent to get started")).toBeTruthy();
});
});
it("shows health status for agents", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
// Active agent with heartbeat should show "Healthy"
expect(screen.getByText("Healthy")).toBeTruthy();
});
});
});
describe("agent creation", () => {
it("shows create form when clicking New Agent button", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
const newAgentButton = screen.getByText("New Agent");
fireEvent.click(newAgentButton);
expect(screen.getByPlaceholderText("Agent name...")).toBeTruthy();
});
it("creates agent with name and role", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
// Open create form
fireEvent.click(screen.getByText("New Agent"));
// Fill in agent name
const nameInput = screen.getByPlaceholderText("Agent name...");
fireEvent.change(nameInput, { target: { value: "My New Agent" } });
// Select role - find by class within the create form
const roleSelect = document.querySelector(".agent-create-form .select") as HTMLSelectElement;
expect(roleSelect).toBeTruthy();
fireEvent.change(roleSelect, { target: { value: "executor" } });
// Click create button
const createButton = screen.getByText("Create");
fireEvent.click(createButton);
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledWith({
name: "My New Agent",
role: "executor",
});
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("My New Agent"),
"success"
);
});
it("does not create agent with empty name", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
const createButton = screen.getByText("Create");
fireEvent.click(createButton);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("handles creation error gracefully", async () => {
mockCreateAgent.mockRejectedValue(new Error("Creation failed"));
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
const nameInput = screen.getByPlaceholderText("Agent name...");
fireEvent.change(nameInput, { target: { value: "Fail Agent" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Creation failed"),
"error"
);
});
});
});
describe("agent state changes", () => {
it("shows Start button for idle agents", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
const startButtons = screen.getAllByTitle("Activate");
expect(startButtons.length).toBeGreaterThan(0);
});
});
it("transitions idle agent to active", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
const startButton = screen.getByTitle("Activate");
fireEvent.click(startButton);
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("active"),
"success"
);
});
it("shows Pause and Stop buttons for active agents", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
// Get all agent cards and find the one with active state
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBeGreaterThan(0);
});
// Find the agent card for the active agent (agent-002)
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();
// Check for Pause and Stop buttons within the active card
const pauseButton = activeCard?.querySelector('[title="Pause"]');
const stopButton = activeCard?.querySelector('[title="Stop"]');
expect(pauseButton).toBeTruthy();
expect(stopButton).toBeTruthy();
});
it("pauses active agent", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBeGreaterThan(0);
});
// Find the agent card for the active agent (agent-002)
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");
});
});
it("stops active agent", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBeGreaterThan(0);
});
// Find the agent card for the active agent (agent-002)
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 stopButton = activeCard?.querySelector('[title="Stop"]') as HTMLElement;
expect(stopButton).toBeTruthy();
fireEvent.click(stopButton);
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated");
});
});
it("shows Resume button for paused agents", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Resume")).toBeTruthy();
});
});
it("resumes paused agent", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Resume")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Resume"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active");
});
});
it("handles state change errors gracefully", async () => {
mockUpdateAgentState.mockRejectedValue(new Error("Invalid transition"));
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Invalid transition"),
"error"
);
});
});
});
describe("agent deletion", () => {
it("shows Delete button only for terminated agents", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Delete")).toBeTruthy();
});
});
it("confirms before deleting agent", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Delete")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Delete"));
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(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Delete")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Delete"));
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004");
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("deleted"),
"success"
);
});
it("handles deletion error gracefully", async () => {
mockDeleteAgent.mockRejectedValue(new Error("Delete failed"));
vi.spyOn(window, "confirm").mockReturnValue(true);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Delete")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Delete"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Delete failed"),
"error"
);
});
});
});
describe("agent filtering", () => {
it("filters agents by state", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
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" });
});
});
it("clears filter when selecting 'all'", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
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" });
});
fireEvent.change(filterSelect, { target: { value: "all" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined);
});
});
});
describe("refresh functionality", () => {
it("refreshes agent list when clicking refresh button", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Refresh")).toBeTruthy();
});
mockFetchAgents.mockClear();
fireEvent.click(screen.getByTitle("Refresh"));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
});
describe("CSS variables for agent states", () => {
it("has CSS variables defined for agent state badges", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Check that style tag is present with CSS variables
const styleTag = document.querySelector("style");
expect(styleTag).toBeTruthy();
expect(styleTag?.textContent).toContain("--state-idle-bg");
expect(styleTag?.textContent).toContain("--state-active-bg");
expect(styleTag?.textContent).toContain("--state-paused-bg");
expect(styleTag?.textContent).toContain("--state-error-bg");
});
});
});