Files
fusion/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
Fusion cc18b2f073 test(FN-4121): complete Step 7 — cover labeled org chart layout toggle
Fusion-Task-Id: FN-4121
Fusion-Task-Lineage: aa8b8ab7-c250-4ce5-b42e-7b7a2ba920b1
2026-05-13 06:29:35 -07:00

2472 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { loadAllAppCss } from "../../test/cssFixture";
import { AgentsView } from "../AgentsView";
import * as apiModule from "../../api";
import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
import { ORG_CHART_LAYOUT_STORAGE_KEY } from "../agentsOrgChartLayout";
// Mock the API module
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../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(),
fetchSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 1 }),
updateSettings: vi.fn().mockResolvedValue({}),
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
fetchPluginRuntimes: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }),
respondToAgentOnboarding: vi.fn().mockResolvedValue({ type: "question", data: { id: "q1", type: "text", question: "?" } }),
retryAgentOnboardingSession: vi.fn().mockResolvedValue({ success: true, sessionId: "onb-1" }),
stopAgentOnboardingGeneration: vi.fn().mockResolvedValue({ success: true }),
cancelAgentOnboarding: vi.fn().mockResolvedValue(undefined),
});
});
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => {
if (!isOpen) return null;
return (
<div role="dialog" aria-label="AI Interview">
<p>Draft ready for review</p>
<button type="button" onClick={onClose}>Cancel</button>
<button
type="button"
onClick={() =>
onUseDraft({
name: "Interview Draft Agent",
role: "reviewer",
title: "Drafted Title",
instructionsText: "Drafted instructions",
thinkingLevel: "low",
maxTurns: 10,
})
}
>
Apply draft to agent form
</button>
</div>
);
},
}));
vi.mock("../AgentDetailView", () => ({
AgentDetailView: ({ agentId, inline, onClose, showInlineBackButton, initialTab, initialRunId, preferActiveRun, onMutationSuccess }: { agentId: string; inline?: boolean; onClose?: () => void; showInlineBackButton?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean; onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void> }) => (
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"} data-initial-tab={initialTab ?? "dashboard"} data-initial-run-id={initialRunId ?? ""} data-prefer-active-run={preferActiveRun ? "true" : "false"}>
{showInlineBackButton ? (
<button type="button" aria-label="Back to agents" onClick={onClose}>Agents</button>
) : null}
<button type="button" onClick={() => void onMutationSuccess?.({ agentId })}>Trigger detail mutation success</button>
Agent detail: {agentId}
</div>
),
relativeTime: () => "just now",
}));
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockViewportMode(),
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
const mockCreateAgent = vi.mocked(apiModule.createAgent);
const mockUpdateAgent = vi.mocked(apiModule.updateAgent);
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);
const mockFetchSettings = vi.mocked((apiModule as any).fetchSettings);
const mockUpdateSettings = vi.mocked((apiModule as any).updateSettings);
const mockClipboardWriteText = vi.fn();
const mockResizeObserverObserve = vi.fn();
const mockResizeObserverDisconnect = vi.fn();
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,
totalInputTokens: 100,
totalOutputTokens: 20,
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",
totalInputTokens: 10,
totalOutputTokens: 5,
lastHeartbeatAt: new Date().toISOString(),
runtimeConfig: { heartbeatIntervalMs: 30000 },
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: "error" as AgentState,
totalInputTokens: 1,
totalOutputTokens: 1,
createdAt: new Date(Date.now() - 259200000).toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("ResizeObserver", class {
observe = mockResizeObserverObserve;
disconnect = mockResizeObserverDisconnect;
});
mockViewportMode.mockReturnValue("desktop");
mockClipboardWriteText.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: mockClipboardWriteText },
});
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
localStorage.clear();
mockFetchAgents.mockResolvedValue(mockAgents);
mockFetchAgentStats.mockResolvedValue({ total: 4, byState: {}, byRole: {} });
mockCreateAgent.mockResolvedValue(mockAgents[0]);
mockUpdateAgent.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([]);
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
mockUpdateSettings.mockResolvedValue({});
});
const openControlsPanel = async () => {
const trigger = await screen.findByRole("button", { name: "Controls" });
fireEvent.click(trigger);
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy();
});
return trigger;
};
const openOverviewPanel = async () => {
const toggle = await screen.findByRole("button", { name: /Overview/i });
if (toggle.getAttribute("aria-expanded") !== "true") {
fireEvent.click(toggle);
}
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
return toggle;
};
describe("rendering", () => {
it("renders the agents view header", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
});
it("renders agent list on mount", async () => {
render(<AgentsView addToast={mockAddToast} />);
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("shows pending approval badge when agent has pending approvals", async () => {
mockFetchAgents.mockResolvedValueOnce([
{ ...mockAgents[0], id: "agent-pending", name: "Pending Agent", pendingApprovalCount: 2 },
]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Pending approvals")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
});
it("formats skill badge labels from SKILL.md paths", async () => {
mockFetchAgents.mockResolvedValueOnce([
{
...mockAgents[0],
id: "agent-skills",
name: "Skill Agent",
metadata: {
skills: ["auto::skills/../../.agents/skills/review/SKILL.md"],
},
},
]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("review")).toBeInTheDocument();
});
expect(screen.queryByText("auto::skills/../../.agents/skills/review/SKILL.md")).toBeNull();
expect(screen.getByText("review")).toHaveAttribute("title", "auto::skills/../../.agents/skills/review/SKILL.md");
});
it("renders cross-pane overview above split layout", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(container.querySelector(".agents-overview-bar")).toBeTruthy();
expect(container.querySelector(".agents-split-layout")).toBeTruthy();
});
const overview = container.querySelector(".agents-overview-bar");
const splitLayout = container.querySelector(".agents-split-layout");
expect(overview?.nextElementSibling).toBe(splitLayout);
const sidebar = container.querySelector(".agents-split-sidebar");
expect(sidebar).toBeTruthy();
expect(sidebar?.querySelector(".agents-overview-bar")).toBeNull();
expect(container.querySelector(".agents-split-detail")).toBeTruthy();
expect(screen.getByText("Select an agent")).toBeInTheDocument();
expect(screen.getByText("Choose an agent from the sidebar to view details")).toBeInTheDocument();
});
it("opens inline detail pane and marks selected card", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: "View details for Test Agent 1" });
fireEvent.click(detailButton);
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
expect(container.querySelector(".agent-card--selected")).toBeTruthy();
});
it("keeps desktop selection in detail pane without rendering sidebar quick-controls strip", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: "View details for Test Agent 2" });
fireEvent.click(detailButton);
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
expect(container.querySelector(".agent-card--selected")).toBeTruthy();
expect(container.querySelector(".agents-sidebar-quick-controls")).toBeNull();
});
it("supports mobile drill-in detail with back navigation", async () => {
mockViewportMode.mockReturnValue("mobile");
const { container } = render(<AgentsView addToast={mockAddToast} />);
expect(container.querySelector(".agents-split-layout")).toBeTruthy();
expect(container.querySelector(".agents-view-content")).toBeTruthy();
expect(container.querySelector(".agents-split-sidebar")).toBeTruthy();
expect(container.querySelector(".agents-split-detail--hidden-mobile")).toBeTruthy();
fireEvent.click(await screen.findByRole("button", { name: "View details for Test Agent 1" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Back to agents" })).toBeTruthy();
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
expect(container.querySelector(".agents-split-sidebar--hidden-mobile")).toBeTruthy();
expect(container.querySelector(".agents-split-detail--hidden-mobile")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Back to agents" }));
await waitFor(() => {
expect(screen.getByText("Select an agent")).toBeInTheDocument();
});
expect(container.querySelector(".agents-split-detail--hidden-mobile")).toBeTruthy();
});
it("closes mobile detail and shows org chart when switching views", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchOrgTree.mockResolvedValue([
{
agent: {
id: "agent-org-1",
name: "Org Lead",
role: "scheduler",
state: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
children: [],
},
]);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(await screen.findByRole("button", { name: "View details for Test Agent 1" }));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
expect(screen.queryByTestId("agent-detail-view")).toBeNull();
expect(screen.getByTestId("agent-org-chart")).toBeTruthy();
expect(screen.getByText("Org Lead")).toBeTruthy();
});
});
it("collapses mobile overview after selecting an active agent card", async () => {
mockViewportMode.mockReturnValue("mobile");
render(<AgentsView addToast={mockAddToast} />);
const overviewToggle = await openOverviewPanel();
fireEvent.click(await screen.findByRole("button", { name: /select agent test agent 2/i }));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-002");
expect(overviewToggle.getAttribute("aria-expanded")).toBe("false");
});
});
it("keeps desktop overview open after selecting an active agent card", async () => {
mockViewportMode.mockReturnValue("desktop");
render(<AgentsView addToast={mockAddToast} />);
const overviewToggle = await openOverviewPanel();
fireEvent.click(await screen.findByRole("button", { name: /select agent test agent 2/i }));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-002");
expect(overviewToggle.getAttribute("aria-expanded")).toBe("true");
});
});
it("shows a loading indicator while the initial agents fetch is pending", async () => {
let resolveAgents: ((value: Agent[]) => void) | undefined;
mockFetchAgents.mockImplementationOnce(
() =>
new Promise<Agent[]>((resolve) => {
resolveAgents = resolve;
}),
);
render(<AgentsView addToast={mockAddToast} />);
const loadingStatus = await screen.findByRole("status");
expect(loadingStatus).toHaveTextContent("Loading agents...");
expect(loadingStatus.getAttribute("aria-live")).toBe("polite");
resolveAgents?.(mockAgents);
await waitFor(() => {
expect(screen.queryByText("Loading agents...")).toBeNull();
});
});
it("hides the loading indicator once agents finish loading", async () => {
let resolveAgents: ((value: Agent[]) => void) | undefined;
mockFetchAgents.mockImplementationOnce(
() =>
new Promise<Agent[]>((resolve) => {
resolveAgents = resolve;
}),
);
render(<AgentsView addToast={mockAddToast} />);
expect(await screen.findByText("Loading agents...")).toBeTruthy();
resolveAgents?.(mockAgents);
await waitFor(() => {
expect(screen.queryByText("Loading agents...")).toBeNull();
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
});
it("keeps existing agents visible during refresh loads", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
let resolveRefresh: ((value: Agent[]) => void) | undefined;
mockFetchAgents.mockImplementationOnce(
() =>
new Promise<Agent[]>((resolve) => {
resolveRefresh = resolve;
}),
);
fireEvent.click(screen.getByTitle("Refresh"));
await waitFor(() => {
expect(screen.queryByText("Loading agents...")).toBeNull();
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
resolveRefresh?.(mockAgents);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(2);
});
});
it("keeps New Agent directly accessible on desktop while controls live in popup", async () => {
render(<AgentsView addToast={mockAddToast} />);
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
await openControlsPanel();
expect(screen.getByLabelText("Filter agents by state")).toBeTruthy();
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Import" }).length).toBeGreaterThan(0);
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
});
it("moves import and new-agent actions into the controls popup on mobile", async () => {
mockViewportMode.mockReturnValue("mobile");
render(<AgentsView addToast={mockAddToast} />);
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
expect(screen.queryByRole("button", { name: "New Agent" })).toBeNull();
await openControlsPanel();
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
});
it("closes controls popup on Escape and outside click", async () => {
render(<AgentsView addToast={mockAddToast} />);
const trigger = await openControlsPanel();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
});
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
});
});
it("keeps metrics and active agents collapsed behind overview disclosure by default", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(container.querySelector(".agent-list")).toBeTruthy();
});
const overviewToggle = screen.getByRole("button", { name: /Overview/i });
expect(overviewToggle.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(".agent-metrics-bar")).toBeNull();
expect(container.querySelector(".active-agents-panel")).toBeNull();
fireEvent.click(overviewToggle);
await waitFor(() => {
expect(overviewToggle.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector(".agent-metrics-bar")).toBeTruthy();
expect(container.querySelector(".active-agents-panel")).toBeTruthy();
});
});
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
});
// Ensure the single-load path still powers dependent UI sections.
await openOverviewPanel();
expect(await screen.findByText("Active Agents (1)")).toBeTruthy();
});
it("renders token stats derived from the currently displayed agents", async () => {
render(<AgentsView addToast={mockAddToast} />);
// Token-usage panel now lives inside the controls popup, not in the
// main view body — open the controls panel before asserting.
await openControlsPanel();
await waitFor(() => {
expect(screen.getByText("Token Usage by Agent")).toBeTruthy();
});
expect(screen.getByText("Input Tokens")).toBeTruthy();
expect(screen.getByText("111")).toBeTruthy();
expect(screen.getByText("Output Tokens")).toBeTruthy();
expect(screen.getByText("26")).toBeTruthy();
expect(screen.getByText("Combined Tokens")).toBeTruthy();
expect(screen.getByText("137")).toBeTruthy();
const tokenRows = screen.getAllByRole("row");
expect(tokenRows[1]).toHaveTextContent("Test Agent 1");
expect(tokenRows[2]).toHaveTextContent("Test Agent 2");
});
it("passes projectId to agent fetches", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ includeEphemeral: false }, projectId);
});
});
it("renders empty state when no agents", async () => {
mockFetchAgents.mockResolvedValue([]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("No agents found")).toBeTruthy();
expect(screen.getByText("Create an agent to get started")).toBeTruthy();
expect(screen.getByRole("button", { name: "Create Agent" })).toBeTruthy();
});
});
it("opens the create dialog from the empty state CTA", async () => {
mockFetchAgents.mockResolvedValue([]);
render(<AgentsView addToast={mockAddToast} />);
const cta = await screen.findByRole("button", { name: "Create Agent" });
fireEvent.click(cta);
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
});
});
it("displays agent states", async () => {
render(<AgentsView addToast={mockAddToast} />);
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("error").length).toBeGreaterThanOrEqual(1);
});
});
describe("active agent card highlight", () => {
it.each(["active", "running"] as const)("applies active highlight state classes across views for %s agents", async (state) => {
const highlightAgent: Agent = {
id: `agent-highlight-${state}`,
name: `Highlight ${state}`,
role: "executor",
state,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
};
mockFetchAgents.mockResolvedValue([highlightAgent]);
mockFetchAgentStats.mockResolvedValue({ total: 1, byState: { [state]: 1 }, byRole: { executor: 1 } });
mockFetchOrgTree.mockResolvedValue([{ agent: highlightAgent, children: [] }]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(document.querySelector(`.agent-card--${state}`)).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
await waitFor(() => {
expect(document.querySelector(`.agent-board-card--${state}`)).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
expect(document.querySelector(`.org-chart-node-card--${state}`)).toBeTruthy();
});
});
it("keeps paused agents out of active highlight classes", async () => {
render(<AgentsView addToast={mockAddToast} />);
const pausedAgentCard = await screen.findByText("Test Agent 3");
const pausedCard = pausedAgentCard.closest(".agent-card");
expect(pausedCard).toBeTruthy();
expect(pausedCard?.classList.contains("agent-card--paused")).toBe(true);
expect(pausedCard?.classList.contains("agent-card--active")).toBe(false);
});
});
it("displays agent task when working on one", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByText("FN-001").length).toBeGreaterThanOrEqual(1);
});
});
it("renders explicit View Details button on list cards", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "View details for Test Agent 1" })).toBeTruthy();
expect(screen.getByRole("button", { name: "View details for Test Agent 2" })).toBeTruthy();
});
expect(screen.getAllByText("Details").length).toBeGreaterThanOrEqual(4);
});
it("keeps a visible icon affordance on split-sidebar action buttons when labels are compacted", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
const sidebarCard = await waitFor(() => {
const card = container.querySelector(".agents-split-sidebar .agent-card");
expect(card).toBeTruthy();
return card as HTMLElement;
});
const actions = sidebarCard.querySelector(".agent-card-actions");
const primaryGroup = sidebarCard.querySelector(".agent-card-actions-group--primary");
const secondaryGroup = sidebarCard.querySelector(".agent-card-actions-group--secondary");
const detailsButton = within(sidebarCard).getByRole("button", { name: "View details for Test Agent 1" });
expect(actions).toBeTruthy();
expect(primaryGroup).toBeTruthy();
expect(secondaryGroup).toBeTruthy();
expect(primaryGroup?.querySelector("button")).toBeTruthy();
expect(secondaryGroup?.contains(detailsButton)).toBe(true);
expect(detailsButton.querySelector("svg")).toBeTruthy();
expect(sidebarCard.querySelectorAll(".agent-card-action-label").length).toBeGreaterThan(0);
});
it("uses a grid-and-wrap containment contract for split-sidebar action rows", () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.agent-card-actions\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*\}/);
expect(css).toMatch(/\.agent-card-actions-group\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*min-width:\s*0;[^}]*\}/);
expect(css).not.toContain(".agents-split-sidebar .agent-card-actions .agent-card-action-label {\n display: none;\n}");
expect(css).toContain("@container agent-card-actions (max-width: calc(var(--space-2xl) * 9))");
expect(css).toContain(".agents-split-sidebar .agent-card-actions {\n grid-template-columns: minmax(0, 1fr);\n }");
expect(css).toContain(".agents-split-sidebar .agent-card-actions .agent-card-action-label {\n display: none;\n }");
});
it("opens matching detail view when clicking View Details button", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "View details for Test Agent 3" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "View details for Test Agent 3" }));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-003");
});
});
it("refreshes left-pane list immediately when detail pane reports a successful mutation", async () => {
mockFetchAgents
.mockResolvedValueOnce(mockAgents)
.mockResolvedValueOnce([
{ ...mockAgents[0], name: "Renamed Agent" },
...mockAgents.slice(1),
]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByRole("button", { name: "View details for Test Agent 1" }));
fireEvent.click(await screen.findByRole("button", { name: "Trigger detail mutation success" }));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(2);
expect(screen.getAllByText("Renamed Agent").length).toBeGreaterThan(0);
});
});
it("opens detail view when clicking anywhere on the agent card body", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
const clickableCard = Array.from(document.querySelectorAll(".agent-card--clickable")).find((element) =>
element.textContent?.includes("Test Agent 1"),
) as HTMLElement | undefined;
expect(clickableCard).toBeTruthy();
fireEvent.click(clickableCard!);
await waitFor(() => {
const detail = screen.getByTestId("agent-detail-view");
expect(detail).toHaveTextContent("agent-001");
expect(detail).toHaveAttribute("data-initial-tab", "dashboard");
expect(detail).toHaveAttribute("data-initial-run-id", "");
});
});
it("opens agent detail in Runs context when clicking Running control", async () => {
const runningAgent: Agent = {
id: "agent-005",
name: "Runner",
role: "executor",
state: "running",
activeRun: {
id: "run-555",
agentId: "agent-005",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
};
mockFetchAgents.mockResolvedValue([runningAgent]);
mockFetchAgentStats.mockResolvedValue({ total: 1, byState: { running: 1 }, byRole: { executor: 1 } });
render(<AgentsView addToast={mockAddToast} />);
const runningButton = await screen.findByRole("button", { name: "View live run details for Runner" });
fireEvent.click(runningButton);
await waitFor(() => {
const detail = screen.getByTestId("agent-detail-view");
expect(detail).toHaveTextContent("agent-005");
expect(detail).toHaveAttribute("data-initial-tab", "runs");
expect(detail).toHaveAttribute("data-initial-run-id", "");
expect(detail).toHaveAttribute("data-prefer-active-run", "true");
});
});
it("shows heartbeat interval control on agent cards with 5m minimum presets", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Agent 2 has heartbeatIntervalMs: 30000 (30s) which should be clamped to 5m
expect(screen.getByDisplayValue("5m")).toBeTruthy();
// Verify all expected presets are present
const select = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
const options = Array.from(select.options).map(o => o.text);
expect(options).toContain("5m");
expect(options).toContain("48h");
expect(options).toContain("72h");
expect(options).toContain("1w");
// Verify old sub-5m presets are NOT present
expect(options).not.toContain("1s");
expect(options).not.toContain("5s");
expect(options).not.toContain("10s");
expect(options).not.toContain("30s");
expect(options).not.toContain("1m");
});
it("renders Last/Next heartbeat timestamps without seconds", async () => {
const lastHeartbeatAt = "2026-05-04T14:23:45.000Z";
mockFetchAgents.mockResolvedValueOnce([
{
...mockAgents[1],
lastHeartbeatAt,
runtimeConfig: { heartbeatIntervalMs: 300000 },
},
]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: { active: 1 }, byRole: { triage: 1 } });
render(<AgentsView addToast={mockAddToast} />);
const lastAt = new Date(lastHeartbeatAt);
const nextAt = new Date(lastAt.getTime() + 300000);
const expectedLast = `Last: ${lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
const expectedNext = `Next: ${nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
await waitFor(() => {
expect(screen.getByText(expectedLast)).toBeTruthy();
expect(screen.getByText(expectedNext)).toBeTruthy();
});
expect(screen.queryByText(/Last: .*:\d{2}:\d{2}/)).toBeNull();
expect(screen.queryByText(/Next: .*:\d{2}:\d{2}/)).toBeNull();
});
it("uses the system default heartbeat interval when runtime config is unset", async () => {
mockFetchAgents.mockResolvedValue([
{
...mockAgents[1],
runtimeConfig: {},
},
]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
expect(intervalSelect.value).toBe("3600000");
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1h");
});
it("updates agent heartbeat interval from preset dropdown", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Change from 5m (clamped from 30s) to 15m
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "900000" } });
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-002",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 900000 }),
}),
undefined,
);
});
});
it("shows Custom... option in dropdown that reveals typed input", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
// Change to Custom... option
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
// Should show custom input with minutes field
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
});
});
it("can enter custom minutes value and save it", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Select Custom... option
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
});
// Enter 7 minutes
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
fireEvent.change(customInput, { target: { value: "7" } });
// Click Save
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
// Should save 7 minutes = 420000 ms
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-002",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 420000 }),
}),
undefined,
);
});
});
it("clamps custom value 1-4 minutes to 5 minutes with info toast", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Select Custom... option
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
});
// Enter 3 minutes
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
fireEvent.change(customInput, { target: { value: "3" } });
// Click Save
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
// Should save 5 minutes (minimum) = 300000 ms
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-002",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 300000 }),
}),
undefined,
);
// Should show info toast about clamping
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("5 minutes (minimum)"),
"success",
);
});
});
it("does not save when custom input is empty", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Select Custom... option
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
});
// Clear the pre-filled value to empty
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
fireEvent.change(customInput, { target: { value: "" } });
// Wait for state to update
await waitFor(() => {
expect((customInput as HTMLInputElement).value).toBe("");
});
// Click Save with empty input
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// Should not call updateAgent
expect(mockUpdateAgent).not.toHaveBeenCalled();
// Should show error toast
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("enter a heartbeat interval"),
"error",
);
});
it("does not save when custom input is non-numeric", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Select Custom... option
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
});
// Clear and enter non-numeric value
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
fireEvent.change(customInput, { target: { value: "abc" } });
// Wait for state to update
await waitFor(() => {
expect((customInput as HTMLInputElement).value).toBe("abc");
});
// Click Save
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// Should not call updateAgent
expect(mockUpdateAgent).not.toHaveBeenCalled();
// Should show error toast
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("valid number"),
"error",
);
});
it("does not save when custom input is zero or negative", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
// Select Custom... option
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
await waitFor(() => {
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
});
// Enter 0
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
fireEvent.change(customInput, { target: { value: "0" } });
// Wait for state to update
await waitFor(() => {
expect((customInput as HTMLInputElement).value).toBe("0");
});
// Click Save
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// Should not call updateAgent
expect(mockUpdateAgent).not.toHaveBeenCalled();
// Should show error toast
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("greater than 0"),
"error",
);
});
it("renders compact error indicator and opens modal with copy/github actions", async () => {
const errorAgent: Agent = {
...mockAgents[0],
id: "agent-error",
name: "Error Agent",
state: "error",
lastError: "something broke",
};
mockFetchAgents.mockResolvedValueOnce([errorAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: { error: 1 }, byRole: { executor: 1 } });
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open error details" })).toBeTruthy();
});
expect(screen.queryByText("something broke")).toBeNull();
expect(screen.queryByLabelText("Agent error details")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
expect(screen.getByLabelText("Agent error details")).toBeTruthy();
expect(screen.getAllByText("something broke").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
await waitFor(() => {
expect(mockClipboardWriteText).toHaveBeenCalledWith("something broke");
});
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
fireEvent.click(screen.getByRole("link", { name: "Report on GitHub" }));
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/Runfusion/Fusion/issues/new?"),
"_blank",
"noopener,noreferrer",
);
expect(openSpy.mock.calls[0]?.[0]).toContain("Surface%3A+AgentsView+list");
openSpy.mockRestore();
});
it("does not render error display without error state and lastError", async () => {
mockFetchAgents.mockResolvedValueOnce([
{ ...mockAgents[0], id: "error-no-text", name: "Error No Text", state: "error", lastError: undefined },
{ ...mockAgents[0], id: "active-with-text", name: "Active With Text", state: "active", lastError: "should not show" },
]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 2, byState: { error: 1, active: 1 }, byRole: { executor: 2 } });
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("Error No Text")).toBeTruthy();
expect(screen.getByText("Active With Text")).toBeTruthy();
});
expect(screen.queryByText("should not show")).toBeNull();
expect(screen.queryByRole("button", { name: "Open error details" })).toBeNull();
});
it("shows refresh button", async () => {
render(<AgentsView addToast={mockAddToast} />);
// Use findBy to ensure React has flushed all pending state updates before asserting.
// This prevents act(...) warnings from any async effects triggered during render.
const refreshBtn = await screen.findByTitle("Refresh");
expect(refreshBtn).toBeTruthy();
});
});
describe("view toggle (list/board)", () => {
it("can toggle between list and board view", async () => {
render(<AgentsView addToast={mockAddToast} />);
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(<AgentsView addToast={mockAddToast} />);
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(4);
});
});
it("persists view toggle preference to project-scoped localStorage", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
expect(localStorage.getItem(scopedKey("fn-agent-view", projectId))).toBe("board");
});
});
it("defaults to list view when no localStorage preference exists", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const listContainer = document.querySelector(".agent-list");
expect(listContainer).toBeTruthy();
});
});
it("marks board view button as active when in board mode", async () => {
render(<AgentsView addToast={mockAddToast} />);
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: {
skills: ["auto::skills/../../.agents/skills/review/SKILL.md", "auto::skills/../../.agents/skills/fusion/SKILL.md"],
},
},
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(<AgentsView addToast={mockAddToast} projectId={projectId} />);
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, { includeEphemeral: false });
});
});
it("renders org chart nodes and opens detail view when clicking a node", async () => {
mockFetchOrgTree.mockResolvedValue(orgTree);
const { container } = render(<AgentsView addToast={mockAddToast} />);
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);
});
expect(container.querySelector(".agents-split-layout")).toBeNull();
expect(container.querySelector(".agents-org-full-view")).toBeTruthy();
fireEvent.click(screen.getByText("Director One"));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
expect(screen.getByRole("button", { name: "Back to org chart" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Back to agents" })).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Back to org chart" }));
await waitFor(() => {
expect(screen.queryByTestId("agent-detail-view")).toBeNull();
expect(screen.getByTestId("agent-org-chart")).toBeTruthy();
});
expect(container.querySelector("[class*='org-chart-node-card--'].agent-card--selected")).toBeTruthy();
});
it("keeps org chart node metadata compact without skill badges", async () => {
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
expect(screen.getByText("Chief Agent")).toBeTruthy();
});
const rootCard = document.querySelector('[class*="org-chart-node-card--"]');
expect(rootCard).toBeTruthy();
expect(within(rootCard as HTMLElement).queryByText("review")).toBeNull();
expect(within(rootCard as HTMLElement).queryByText("fusion")).toBeNull();
expect(within(rootCard as HTMLElement).queryByText("+1")).toBeNull();
expect((rootCard as HTMLElement).querySelector(".org-chart-node__skill")).toBeNull();
});
it("sizes org chart subtree containers based on descendant leaf counts", async () => {
mockFetchOrgTree.mockResolvedValue(orgTree);
const { container } = render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
expect(screen.getByText("Chief Agent")).toBeTruthy();
});
const rootNode = screen.getByText("Chief Agent").closest(".org-chart-node") as HTMLElement;
const nestedParentNode = screen.getByText("Director One").closest(".org-chart-node") as HTMLElement;
const leafNode = screen.getByText("Manager Alpha").closest(".org-chart-node") as HTMLElement;
const rootChildren = rootNode.querySelector(":scope > .org-chart-children") as HTMLElement;
expect(rootNode.style.getPropertyValue("--org-chart-subtree-leaves")).toBe("2");
expect(nestedParentNode.style.getPropertyValue("--org-chart-subtree-leaves")).toBe("1");
expect(leafNode.style.getPropertyValue("--org-chart-subtree-leaves")).toBe("1");
expect(rootChildren).toBeTruthy();
expect(rootChildren.className).toContain("org-chart-children");
expect(rootChildren.style.getPropertyValue("--org-chart-first-child-leaves")).toBe("1");
expect(rootChildren.style.getPropertyValue("--org-chart-last-child-leaves")).toBe("1");
expect(container.querySelectorAll(".org-chart-node--has-children").length).toBeGreaterThan(0);
});
it("uses tokenized connector edge offsets for org chart child bars", () => {
const css = loadAllAppCss();
expect(css).toContain("--org-chart-first-child-center-offset");
expect(css).toContain("--org-chart-last-child-center-offset");
expect(css).toContain("left: var(--org-chart-first-child-center-offset)");
expect(css).toContain("right: var(--org-chart-last-child-center-offset)");
expect(css).toContain(".org-chart-children > .org-chart-node::before");
});
it("keeps a compact mobile Agents label visible, anchors the controls popup to the action row, and expands view toggles to 36px touch targets", () => {
const css = loadAllAppCss();
expect(css).toContain(".agents-view-primary-actions {\n position: relative;");
expect(css).toContain(".agent-controls-panel {\n position: absolute;\n top: calc(100% + var(--space-sm));\n right: 0;");
expect(css).toContain(".agents-view-title h2 {\n display: block;\n font-size: var(--space-lg);");
expect(css).toContain(".agent-controls-mobile-actions {");
expect(css).toContain(".agent-controls-mobile-actions .btn {");
expect(css).toContain(".agents-view-controls .view-toggle .view-toggle-btn {");
expect(css).toContain("min-width: calc(var(--space-lg) * 2 + var(--space-xs));");
expect(css).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs));");
});
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
const chart = screen.getByTestId("agent-org-chart");
expect(chart.getAttribute("data-layout-mode")).toBe("vertical");
expect(chart.className).toContain("agent-org-chart--vertical");
});
fireEvent.click(screen.getByText("Director One"));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
});
clientWidthSpy.mockRestore();
});
it("keeps org chart horizontal layout mode when viewport is wide enough", async () => {
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1920);
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
await waitFor(() => {
const chart = screen.getByTestId("agent-org-chart");
expect(chart.getAttribute("data-layout-mode")).toBe("horizontal");
expect(chart.className).not.toContain("agent-org-chart--vertical");
});
clientWidthSpy.mockRestore();
});
it("supports toggling org chart layout preference and persists it", async () => {
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1920);
localStorage.setItem(scopedKey("fn-agent-view", projectId), "org");
localStorage.setItem(scopedKey(ORG_CHART_LAYOUT_STORAGE_KEY, projectId), "vertical");
mockFetchOrgTree.mockResolvedValue(orgTree);
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
const chart = await screen.findByTestId("agent-org-chart");
const toggle = screen.getByTestId("agent-org-chart-layout-toggle");
const horizontalButton = within(toggle).getByRole("button", { name: "Horizontal layout" });
const verticalButton = within(toggle).getByRole("button", { name: "Vertical layout" });
const autoButton = within(toggle).getByRole("button", { name: "Automatic layout" });
expect(within(horizontalButton).getByText("Horizontal")).toBeTruthy();
expect(within(verticalButton).getByText("Vertical")).toBeTruthy();
expect(within(autoButton).getByText("Auto")).toBeTruthy();
expect(verticalButton.getAttribute("aria-pressed")).toBe("true");
expect(horizontalButton.getAttribute("aria-pressed")).toBe("false");
expect(autoButton.getAttribute("aria-pressed")).toBe("false");
expect(chart.getAttribute("data-layout-mode")).toBe("vertical");
fireEvent.click(horizontalButton);
await waitFor(() => {
expect(chart.getAttribute("data-layout-mode")).toBe("horizontal");
expect(localStorage.getItem(scopedKey(ORG_CHART_LAYOUT_STORAGE_KEY, projectId))).toBe("horizontal");
});
fireEvent.click(autoButton);
await waitFor(() => {
expect(chart.getAttribute("data-layout-mode")).toBe("horizontal");
expect(localStorage.getItem(scopedKey(ORG_CHART_LAYOUT_STORAGE_KEY, projectId))).toBe("auto");
});
fireEvent.click(verticalButton);
await waitFor(() => {
expect(chart.getAttribute("data-layout-mode")).toBe("vertical");
});
clientWidthSpy.mockRestore();
});
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchOrgTree.mockResolvedValue(orgTree);
const { container } = render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
const controls = await screen.findByTestId("agent-org-chart-controls");
expect(controls).toBeTruthy();
expect(screen.getByText("100%")).toBeTruthy();
const viewport = screen.getByTestId("agent-org-chart-viewport");
expect(viewport).toBeTruthy();
const canvas = container.querySelector(".agent-org-chart-canvas");
expect(canvas?.className).toContain("agent-org-chart-canvas--zoom-100");
fireEvent.click(within(controls).getByTitle("Zoom in"));
await waitFor(() => {
expect(screen.getByText("125%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-125");
});
fireEvent.click(within(controls).getByTitle("Fit org chart"));
await waitFor(() => {
expect(screen.getByText("100%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
});
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(<AgentsView addToast={mockAddToast} />);
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();
expect(screen.getByRole("button", { name: "Create Agent" })).toBeTruthy();
});
});
it("shows loading state while org chart request is in flight", async () => {
let resolveOrgTree: ((value: OrgTreeNode[]) => void) | undefined;
mockFetchOrgTree.mockImplementation(
() =>
new Promise<OrgTreeNode[]>((resolve) => {
resolveOrgTree = resolve;
}),
);
render(<AgentsView addToast={mockAddToast} />);
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(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// 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(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
const filterSelect = screen.getByLabelText("Filter agents by state");
fireEvent.change(filterSelect, { target: { value: "active" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active", includeEphemeral: false }, undefined);
});
});
it("clears filter when selecting 'all'", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
const filterSelect = screen.getByLabelText("Filter agents by state");
fireEvent.change(filterSelect, { target: { value: "idle" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle", includeEphemeral: false }, undefined);
});
fireEvent.change(filterSelect, { target: { value: "all" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: false }, undefined);
});
});
});
describe("show system agents toggle", () => {
it("renders the system agents checkbox", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// Checkbox should be unchecked by default
const checkbox = screen.getByLabelText("Show system agents") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("passes includeEphemeral: false by default to fetchAgents", async () => {
render(<AgentsView addToast={mockAddToast} />);
// Default call should include includeEphemeral: false
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: false }, undefined);
});
});
it("toggles system agents visibility when checkbox is clicked", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await openControlsPanel();
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: true }, projectId);
});
});
it("combines system agents toggle with state filter", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await openControlsPanel();
// First enable system agents toggle
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: true }, projectId);
});
// Then filter by state
const filterSelect = screen.getByLabelText("Filter agents by state");
fireEvent.change(filterSelect, { target: { value: "active" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", includeEphemeral: true }, projectId);
});
});
it("hides system agents by default and reveals them when Show system agents is enabled", async () => {
const systemAgents: Agent[] = [
{
id: "agent-sys-001",
name: "executor-FN-TEST",
role: "executor" as AgentCapability,
state: "active" as AgentState,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: { agentKind: "task-worker" },
},
];
// Return system agent even when includeEphemeral is false to verify
// client-side filtering still hides it unless the toggle is enabled.
mockFetchAgents.mockResolvedValue([...mockAgents.slice(0, 3), ...systemAgents]);
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
});
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
await openControlsPanel();
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ includeEphemeral: true }, projectId);
expect(screen.getAllByText("executor-FN-TEST").length).toBeGreaterThan(0);
});
});
});
describe("create new agent", () => {
it("can create new agent via multi-step dialog", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
// Open create dialog
fireEvent.click(screen.getByText("New Agent"));
// Step 0: switch to Custom tab and fill in agent name
fireEvent.click(screen.getByRole("tab", { name: "Custom agent" }));
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(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
// Presets tab is default and custom fields appear after switching tabs
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Preset personas", selected: true })).toBeTruthy();
});
fireEvent.click(screen.getByRole("tab", { name: "Custom agent" }));
expect(screen.getByPlaceholderText("e.g. Frontend Reviewer")).toBeTruthy();
});
it("keeps legacy dialog launch when agent onboarding flag is disabled", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={false} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "AI Interview" })).toBeNull();
});
});
it("keeps New Agent launch on the standard dialog when agent onboarding flag is enabled", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={true} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
expect(screen.getByRole("button", { name: "AI Interview" })).toBeTruthy();
});
});
it("launches interview from AgentsView and only applies draft after review confirmation", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={true} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
fireEvent.click(screen.getByRole("button", { name: "AI Interview" }));
const interviewDialog = await screen.findByRole("dialog", { name: "AI Interview" });
expect(screen.getByText("Draft ready for review")).toBeTruthy();
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(within(interviewDialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "AI Interview" })).toBeNull();
});
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "AI Interview" }));
await screen.findByRole("dialog", { name: "AI Interview" });
fireEvent.click(screen.getByRole("button", { name: "Apply draft to agent form" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("tab", { name: "Custom agent" }));
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
expect(nameInput.value).toBe("Interview Draft Agent");
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("does not allow proceeding with empty name", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
// Wait for the dialog to settle after the model fetch completes
await waitFor(() => {
const nextBtn = screen.getByText("Next");
expect(nextBtn).toBeTruthy();
});
// 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(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
fireEvent.click(screen.getByRole("tab", { name: "Custom 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(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
expect(mockStartAgentRun).not.toHaveBeenCalled();
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("active"),
"success"
);
});
it("can pause active agent", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBeGreaterThan(0);
});
// Find the active agent card
const activeCard = Array.from(document.querySelectorAll(".agent-card")).find(
(card) => card.textContent?.includes("agent-002")
) ?? null;
expect(activeCard).toBeTruthy();
const pauseButton = (activeCard as Element | null)?.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 without manual run trigger", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Resume")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Resume"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active", undefined);
});
expect(mockStartAgentRun).not.toHaveBeenCalled();
});
it("optimistically updates the card state before state API resolves", async () => {
let resolveTransition!: () => void;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard).toBeTruthy();
expect(targetCard?.textContent).toContain("active");
});
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("rolls back optimistic state when the state API fails", async () => {
let rejectTransition!: (error: Error) => void;
const transitionPromise = new Promise<Agent>((_, reject) => {
rejectTransition = reject;
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("active");
});
rejectTransition?.(new Error("State change failed"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("idle");
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("State change failed"),
"error"
);
});
it("prevents concurrent state transitions for the same agent", async () => {
let resolveTransition!: () => void;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
expect(pauseButton).toBeTruthy();
expect(pauseButton?.disabled).toBe(true);
});
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
if (pauseButton) {
fireEvent.click(pauseButton);
}
expect(mockUpdateAgentState).toHaveBeenCalledTimes(1);
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("handles state change error gracefully", async () => {
mockUpdateAgentState.mockRejectedValue(new Error("State change failed"));
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("State change failed"),
"error"
);
});
});
it("does not start run when pausing agent", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBeGreaterThan(0);
});
// Find the active agent card
const activeCard = Array.from(document.querySelectorAll(".agent-card")).find(
(card) => card.textContent?.includes("agent-002")
) ?? null;
const pauseButton = (activeCard as Element | null)?.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("Run Now button", () => {
it("renders compact sidebar action controls with visible icon affordances (FN-3902/FN-3923)", async () => {
const activeWithoutTaskId = { ...mockAgents[1] };
delete activeWithoutTaskId.taskId;
mockFetchAgents.mockResolvedValue([
mockAgents[0],
activeWithoutTaskId,
mockAgents[2],
mockAgents[3],
]);
render(<AgentsView addToast={mockAddToast} />);
const runNowButton = await screen.findByTitle("Run Now");
const pauseButton = await screen.findByTitle("Pause");
const activeCard = runNowButton.closest(".agent-card");
const detailsButton = activeCard?.querySelector('[aria-label^="View details for "]') as HTMLButtonElement | null;
expect(detailsButton).toBeTruthy();
expect(runNowButton.querySelector(".agent-card-action-label")).toBeTruthy();
expect(pauseButton.querySelector(".agent-card-action-label")).toBeTruthy();
expect(detailsButton?.querySelector(".agent-card-action-label")).toBeTruthy();
expect(detailsButton?.querySelector("svg")).toBeTruthy();
});
it("shows Run Now button for active agent without taskId", async () => {
const activeWithoutTaskId = { ...mockAgents[1] };
delete activeWithoutTaskId.taskId;
mockFetchAgents.mockResolvedValue([
mockAgents[0],
activeWithoutTaskId,
mockAgents[2],
mockAgents[3],
]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Run Now")).toBeTruthy();
});
});
it("Run Now button calls startAgentRun for active agent without taskId", async () => {
const activeWithoutTaskId = { ...mockAgents[1] };
delete activeWithoutTaskId.taskId;
mockFetchAgents.mockResolvedValue([
mockAgents[0],
activeWithoutTaskId,
mockAgents[2],
mockAgents[3],
]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Run Now")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Run Now"));
await waitFor(() => {
expect(mockStartAgentRun).toHaveBeenCalledWith(
"agent-002",
undefined,
expect.objectContaining({
source: "on_demand",
triggerDetail: "Triggered from dashboard",
}),
);
});
});
});
describe("delete agent", () => {
it("shows Delete button for idle and paused agents in default view", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
});
});
it("does not show Delete button for active agents", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const allCards = Array.from(document.querySelectorAll(".agent-card"));
const activeCard = allCards.find((card) => card.textContent?.includes("agent-002")) ?? null;
expect((activeCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
});
});
it("shows Delete button for paused agents", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const allCards = Array.from(document.querySelectorAll(".agent-card"));
const pausedCard = allCards.find((card) => card.textContent?.includes("agent-003")) ?? null;
expect((pausedCard as Element | null)?.querySelector('[title="Delete"]')).toBeTruthy();
});
});
it("deletes idle agent after confirmation (from default view)", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Find the delete button for idle agent (agent-001)
const idleCard = Array.from(document.querySelectorAll(".agent-card")).find(
(card) => card.textContent?.includes("agent-001")
) ?? null;
const idleDeleteBtn = (idleCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
fireEvent.click(idleDeleteBtn);
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", undefined);
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("deleted"),
"success"
);
});
it("deletes paused agent after confirmation (from default view)", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
const pausedCard = Array.from(document.querySelectorAll(".agent-card")).find(
(card) => card.textContent?.includes("agent-003"),
) ?? null;
const pausedDeleteBtn = (pausedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
expect(pausedDeleteBtn).toBeTruthy();
fireEvent.click(pausedDeleteBtn);
});
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-003", undefined);
});
});
});
describe("refresh functionality", () => {
it("refreshes agent list when clicking refresh button", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Refresh")).toBeTruthy();
});
mockFetchAgents.mockClear();
fireEvent.click(screen.getByTitle("Refresh"));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
});
describe("active agents panel selection", () => {
it("renders active agents panel when agents are active", async () => {
// agent-002 is active with taskId FN-001
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
});
// Should have a live agent card for the active agent
const liveAgentCards = document.querySelectorAll(".live-agent-card");
expect(liveAgentCards.length).toBe(1);
});
it("opens AgentDetailView when clicking an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
});
// Find and click the live agent card
const liveAgentCard = document.querySelector(".live-agent-card");
expect(liveAgentCard).toBeTruthy();
fireEvent.click(liveAgentCard!);
await waitFor(() => {
// Should open detail view for agent-002 (the active agent)
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-002");
});
});
it("opens AgentDetailView when pressing Enter on an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
});
// Find the live agent card
const liveAgentCard = document.querySelector(".live-agent-card") as HTMLElement;
expect(liveAgentCard).toBeTruthy();
// Focus and press Enter
liveAgentCard.focus();
fireEvent.keyDown(liveAgentCard, { key: "Enter" });
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-002");
});
});
it("opens AgentDetailView when pressing Space on an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
});
// Find the live agent card
const liveAgentCard = document.querySelector(".live-agent-card") as HTMLElement;
expect(liveAgentCard).toBeTruthy();
// Focus and press Space
liveAgentCard.focus();
fireEvent.keyDown(liveAgentCard, { key: " " });
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-002");
});
});
it("live agent cards have proper accessibility attributes", async () => {
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
});
const liveAgentCard = document.querySelector(".live-agent-card") as HTMLElement;
expect(liveAgentCard).toBeTruthy();
// Check accessibility attributes
expect(liveAgentCard.getAttribute("role")).toBe("button");
expect(liveAgentCard.getAttribute("tabIndex")).toBe("0");
expect(liveAgentCard.getAttribute("aria-label")).toBe("Select agent Test Agent 2");
});
it("does not show active agents panel when no agents are active", async () => {
// Create agents with no active ones
const inactiveAgents: Agent[] = [
{
id: "agent-005",
name: "Idle Agent",
role: "executor" as AgentCapability,
state: "idle" as AgentState,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
mockFetchAgents.mockResolvedValue(inactiveAgents);
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.queryByText(/^Active Agents \(/)).toBeNull();
});
});
it("opens AgentDetailView for spawned agents in the active panel", async () => {
// Simulate spawned agents by having multiple active agents
const spawnedAgents: Agent[] = [
...mockAgents,
{
id: "spawned-001",
name: "Spawned Worker",
role: "custom" as AgentCapability,
state: "active" as AgentState,
taskId: "FN-100",
lastHeartbeatAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
mockFetchAgents.mockResolvedValue(spawnedAgents);
render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => {
expect(screen.getByText("Active Agents (2)")).toBeTruthy();
});
// Find and click the spawned agent card
const liveAgentCards = document.querySelectorAll(".live-agent-card");
expect(liveAgentCards.length).toBe(2);
// Click on the spawned agent
const spawnedCard = Array.from(liveAgentCards).find(
card => card.textContent?.includes("Spawned Worker")
);
expect(spawnedCard).toBeTruthy();
fireEvent.click(spawnedCard!);
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("spawned-001");
});
});
});
describe("global heartbeat multiplier", () => {
it("renders the global heartbeat speed control", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// Check the slider and preset are rendered
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
// Check helper text
expect(screen.getByText(/Scales all agent heartbeat intervals/)).toBeTruthy();
});
it("loads heartbeat multiplier from settings", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 2.5 });
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" }) as HTMLInputElement;
expect(slider.value).toBe("2.5");
});
it("saves heartbeat multiplier when slider changes", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// Change the slider
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
fireEvent.change(slider, { target: { value: "3" } });
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith({ heartbeatMultiplier: 3 }, undefined);
expect(mockAddToast).toHaveBeenCalledWith("Heartbeat speed set to ×3.0", "success");
});
});
it("saves heartbeat multiplier when preset is selected", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// Change the preset
const preset = screen.getByLabelText("Heartbeat speed preset") as HTMLSelectElement;
fireEvent.change(preset, { target: { value: "0.5" } });
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith({ heartbeatMultiplier: 0.5 }, undefined);
});
});
it("disables control while saving", async () => {
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
mockUpdateSettings.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
render(<AgentsView addToast={mockAddToast} />);
await openControlsPanel();
// Change the slider - this should start the save
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
fireEvent.change(slider, { target: { value: "2" } });
// Both controls should be disabled while saving
await waitFor(() => {
expect(slider).toBeDisabled();
});
});
});
});