import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
import type { AgentCapability, AgentDetail } from "../../api";
// Mock the API functions
vi.mock("../../api", () => ({
fetchAgent: vi.fn(),
updateAgent: vi.fn(),
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
}));
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
id: string;
name: string;
role: AgentCapability;
state: "idle" | "active" | "paused" | "terminated";
taskId?: string;
}> = {}): AgentDetail => ({
id: "agent-001",
name: "Test Agent",
role: "executor" as AgentCapability,
state: "active",
taskId: "FN-001",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
lastHeartbeatAt: "2024-01-01T00:05:00.000Z",
metadata: {},
heartbeatHistory: [],
activeRun: {
id: "run-001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
},
completedRuns: [
{
id: "run-002",
agentId: "agent-001",
startedAt: "2023-12-31T00:00:00.000Z",
endedAt: "2023-12-31T00:05:00.000Z",
status: "completed",
},
],
...overrides,
} as AgentDetail);
beforeEach(() => {
vi.clearAllMocks();
mockFetchAgent.mockResolvedValue(createMockAgent());
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
});
it("shows loading state initially", () => {
render(
);
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
});
it("defines CSS variables for agent state tokens in the global stylesheet", async () => {
render(
);
await waitFor(() => {
const headings = screen.getAllByRole("heading", { level: 2 });
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
// Verify state CSS variables are defined in the global stylesheet (styles.css)
// (previously these were in inline style blocks, now they're in the global :root)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toContain("--state-idle-bg:");
expect(stylesContent).toContain("--state-active-bg:");
expect(stylesContent).toContain("--state-paused-bg:");
expect(stylesContent).toContain("--state-error-bg:");
expect(stylesContent).toContain("--state-idle-text:");
expect(stylesContent).toContain("--state-active-text:");
});
it("uses token-based state colors for badges instead of hardcoded hex", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Verify badge styles use CSS variable references for background, not hex values
const badges = document.querySelectorAll(".badge, .inline-badge");
badges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
// Background should use var(--state-*) references, not raw rgba() or hex
if (style.includes("background")) {
expect(style).toContain("var(--state-");
// Should not use raw rgba() for state backgrounds
expect(style).not.toMatch(/background:\s*rgba\(/);
}
});
});
it("uses token-based colors for health status instead of hardcoded hex", async () => {
render(
);
await waitFor(() => {
// The mock agent is active with a heartbeat from 2024, so it should show "Unresponsive"
const hasHealthStatus = screen.queryAllByText(/Healthy|Unresponsive|Idle/).length > 0;
expect(hasHealthStatus).toBe(true);
});
// Health badges in header should use var(--state-*) references, not raw hex
const headerBadges = document.querySelectorAll(".agent-detail-badges .badge");
headerBadges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
if (style.includes("color:") && !style.includes("var(--state-")) {
// If the color is not a state variable, it should still be a CSS variable
expect(style).toMatch(/color:\s*var\(/);
}
});
});
it("uses token-based color references for success and error states", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Navigate to Runs tab to trigger rendering of run-related content
fireEvent.click(screen.getByText("Runs"));
// Verify that the global stylesheet defines --color-success and --color-error
// (previously checked in inline style blocks, now verified by reading styles.css)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toMatch(/--color-success:/);
expect(stylesContent).toMatch(/--color-error:/);
});
it("uses global design tokens instead of component-local aliases", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Previously the component defined local aliases like --bg-primary, --accent, etc.
// Now these are replaced with direct global token references in the CSS classes.
// Verify the global stylesheet defines the real tokens that the component uses.
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
// The component classes now use --surface, --todo, --text, --card-hover directly
expect(stylesContent).toMatch(/--surface:/);
expect(stylesContent).toMatch(/--todo:/);
expect(stylesContent).toMatch(/--text:/);
expect(stylesContent).toMatch(/--card-hover:/);
});
it("displays agent name in header after loading", async () => {
render(
);
// Wait for the h2 element specifically (the header title)
await waitFor(() => {
const headings = screen.getAllByRole("heading", { level: 2 });
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
});
it("fetches the agent using the active project context", async () => {
render(
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", "proj_123");
});
});
it("displays role badge", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("executor")).toBeInTheDocument();
});
});
it("displays state badge", async () => {
render(
);
await waitFor(() => {
// There should be at least one element with "active" (could be in badge or inline-badge)
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
});
it("shows all tabs", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("Runs")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
});
it("shows Pause button for active agent", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Pause")).toBeInTheDocument();
});
});
it("shows Resume button for paused agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Resume")).toBeInTheDocument();
});
});
it("shows Delete button for terminated agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Delete")).toBeInTheDocument();
});
});
it("shows Delete button for idle agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Delete")).toBeInTheDocument();
});
});
it("shows statistics section on dashboard", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Total Runs")).toBeInTheDocument();
});
});
it("displays agent ID in footer", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("agent-001")).toBeInTheDocument();
});
});
it("calls API with correct agentId", async () => {
render(
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", undefined);
});
});
it("displays health status indicator", async () => {
render(
);
await waitFor(() => {
// Health status should be either Healthy, Unresponsive, or Idle
const healthTexts = ["Healthy", "Unresponsive", "Idle"];
const hasHealthStatus = healthTexts.some(text =>
document.body.textContent?.includes(text)
);
expect(hasHealthStatus).toBe(true);
});
});
it("shows Live Run on runs tab when agent has active run", async () => {
const user = userEvent.setup();
render(
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
});
// ── Advanced Settings (Config Tab) ────────────────────────────────────
describe("Advanced Settings", () => {
const navigateToSettings = async (user: ReturnType) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders advanced settings form fields on Settings tab", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Log Level")).toBeInTheDocument();
});
});
it("shows empty fields when metadata has no advanced settings", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("");
});
});
it("pre-fills fields from agent metadata", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: {
heartbeatIntervalMs: 15000,
maxRetries: 5,
logLevel: "debug",
},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15000");
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
expect(retriesInput.value).toBe("5");
const logLevelSelect = screen.getByLabelText("Log Level") as HTMLSelectElement;
expect(logLevelSelect.value).toBe("debug");
});
});
it("shows Save Settings button disabled when no changes", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Save Settings")).toBeDisabled();
});
});
it("enables Save Settings when a field is changed", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Simulate setting a non-numeric value via React's internal value setter
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
nativeInputValueSetter?.call(heartbeatInput, 'abc');
fireEvent.change(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("shows validation error for number below minimum", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "500");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at least 1,000/)).toBeInTheDocument();
});
});
it("shows validation error for number above maximum", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "99");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at most 10/)).toBeInTheDocument();
});
});
it("calls updateAgent with correct metadata on save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
{ metadata: expect.objectContaining({ heartbeatIntervalMs: 15000 }) },
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Advanced settings saved", "success");
});
it("forwards projectId to updateAgent", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "20000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ metadata: expect.any(Object) }),
"proj_456",
);
});
});
it("re-fetches agent after successful save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Initial fetch + save-triggered refetch
const initialFetchCount = vi.mocked(fetchAgent).mock.calls.length;
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "7");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(vi.mocked(fetchAgent).mock.calls.length).toBeGreaterThan(initialFetchCount);
});
});
it("shows error toast on save failure", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "2");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to save settings"),
"error",
);
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Type "abc" directly into a text input
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "abc");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("pre-fills and persists logLevel select field", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { logLevel: "debug" },
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const logLevelSelect = await screen.findByLabelText("Log Level");
expect((logLevelSelect as HTMLSelectElement).value).toBe("debug");
});
it("clears metadata key when field is cleared to empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
await user.clear(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
}),
undefined,
);
});
});
it("persists existing non-advanced metadata keys during save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { customKey: "preserved", heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "45000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const metadata = (call as any)[1].metadata;
expect(metadata.customKey).toBe("preserved");
expect(metadata.heartbeatIntervalMs).toBe(45000);
});
});
});
});