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 style block", 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 component's style block const styleElements = document.querySelectorAll("style"); const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join(""); expect(allCss).toContain("--state-idle-bg"); expect(allCss).toContain("--state-active-bg"); expect(allCss).toContain("--state-paused-bg"); expect(allCss).toContain("--state-error-bg"); expect(allCss).toContain("--state-idle-text"); expect(allCss).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 in CSS instead of undefined vars", async () => { render( ); await waitFor(() => { expect(screen.getAllByText("active").length).toBeGreaterThan(0); }); // Navigate to Runs tab to trigger rendering of run-related style blocks fireEvent.click(screen.getByText("Runs")); // Verify style blocks use --color-success and --color-error with fallbacks // (not bare --success or --error which are undefined in the root CSS) await waitFor(() => { const styleElements = document.querySelectorAll("style"); const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join(""); expect(allCss).toMatch(/var\(--color-success/); expect(allCss).toMatch(/var\(--color-error/); }); }); it("defines component-local aliases for undefined CSS tokens", async () => { render( ); await waitFor(() => { expect(screen.getAllByText("active").length).toBeGreaterThan(0); }); // Verify that component-local aliases are defined for tokens used in the style block // These map to real global tokens so they don't fall back to browser defaults const styleElements = document.querySelectorAll("style"); const allCss = Array.from(styleElements).map(el => el.textContent ?? "").join(""); expect(allCss).toContain("--bg-primary: var(--surface"); expect(allCss).toContain("--accent: var(--todo"); expect(allCss).toContain("--text-primary: var(--text"); expect(allCss).toContain("--bg-hover: var(--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); }); }); }); });