Production typecheck (tsconfig.json + tsconfig.app.json) was already
clean, but a third config that includes test files surfaced 661 errors
across 60+ test files — accumulated drift between mock fixtures and
production types. Six parallel typescript-pro agents fixed every one
without touching production code.
Per-scope before/after (errors → 0):
ChatView 183
Mailbox + Agent suite (5 files) 156
Task / Modal suite (6 files) 127
App + small components (12 files) 96
Hooks + api/auth (8 files) 48
Long tail (32 files) 51
-----------------------------------------------------------------
Total 661
Major fix categories:
- Untyped state objects inferring `never[]` / `null` literals (root
cause of ~120 errors in ChatView alone — added a single
`UseChatReturn` annotation)
- Mock objects missing fields that became required: `WorkflowStep.mode`,
`ChatMessage.thinkingOutput / metadata`, `ChatSession.projectId`,
`Task.log`, `ProjectHealth` fields, `PtyTerminalSessionInfo.createdAt`,
`Agent.metadata`, `InboxResponse.total`, etc.
- Mock objects with stale fields that no longer exist:
`AgentBudgetStatus.budgetPeriod`, `truncated` on log responses,
`OutboxResponse.unreadCount`, `MergeResult.source/target/details`
- Modal props that became required (e.g. `PlanningModeModal.onTasksCreated`)
- String literals not in narrowed unions (`Column`, `WorkflowStepPhase`,
`InsightStatus`, `AgentLogType`, etc.)
- `querySelector` returning `Element` cast to `HTMLElement` for
`@testing-library/react`'s `within()`
- Vitest mock typing: `.mock.calls` access needing `vi.mocked(...)`,
zero-param tuple handling, generic `vi.fn(() => [])` inferring
`never[]`
Helpers introduced in test files (no shared infra):
- `makeSettings(overrides)` in ModelSelectorTab.test.tsx
- `makePromptOverrides(overrides)` in AgentPromptsManager.test.tsx
- `FileBrowserTestOverrides` type alias in FileBrowser.test.tsx
- `makeInboxResponse / makeOutboxResponse` in MailboxView.test.tsx
Verification:
- tsc -p tsconfig.json: exit 0
- tsc -p tsconfig.app.json: exit 0
- tsc -p tsconfig.test-check.json (new — includes test files): exit 0
- vitest run: 9639 / 9641 (2 pre-existing failures
in terminal-mobile-keyboard-layout.test.ts
unrelated to this work; verified via
`git stash` + run on clean HEAD)
Adds packages/dashboard/tsconfig.test-check.json to keep this regression
guard available locally — same as tsconfig.app.json minus the test
exclude.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1769 lines
62 KiB
TypeScript
1769 lines
62 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||
import { AgentsView } from "../AgentsView";
|
||
import * as apiModule from "../../api";
|
||
import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api";
|
||
import { scopedKey } from "../../utils/projectStorage";
|
||
|
||
// Mock the API module
|
||
vi.mock("../../api", () => ({
|
||
fetchAgents: vi.fn(),
|
||
fetchAgentStats: vi.fn(),
|
||
createAgent: vi.fn(),
|
||
updateAgent: vi.fn(),
|
||
updateAgentState: vi.fn(),
|
||
deleteAgent: vi.fn(),
|
||
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([]),
|
||
}));
|
||
|
||
vi.mock("../AgentDetailView", () => ({
|
||
AgentDetailView: ({ agentId }: { agentId: string }) => <div data-testid="agent-detail-view">Agent detail: {agentId}</div>,
|
||
}));
|
||
|
||
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);
|
||
|
||
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: "terminated" as AgentState,
|
||
totalInputTokens: 1,
|
||
totalOutputTokens: 1,
|
||
createdAt: new Date(Date.now() - 259200000).toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
metadata: {},
|
||
},
|
||
];
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
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;
|
||
};
|
||
|
||
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 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 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.getByRole("button", { name: "Import" })).toBeTruthy();
|
||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||
expect(screen.getByLabelText("Heartbeat speed preset")).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("renders metrics above the main collection and active panel after it", async () => {
|
||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||
|
||
await waitFor(() => {
|
||
expect(container.querySelector(".agent-list")).toBeTruthy();
|
||
expect(container.querySelector(".agent-metrics-bar")).toBeTruthy();
|
||
expect(container.querySelector(".active-agents-panel")).toBeTruthy();
|
||
});
|
||
|
||
const list = container.querySelector(".agent-list");
|
||
const metrics = container.querySelector(".agent-metrics-bar");
|
||
const activePanel = container.querySelector(".active-agents-panel");
|
||
expect(list && metrics && activePanel).toBeTruthy();
|
||
// Metrics bar sits above the agent list (top-of-view stats placement).
|
||
expect(metrics!.compareDocumentPosition(list!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||
// Active agents panel comes after the main agent collection.
|
||
expect(list!.compareDocumentPosition(activePanel!) & Node.DOCUMENT_POSITION_FOLLOWING).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.
|
||
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("terminated").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
it("shows terminated agents when explicitly filtered", async () => {
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
await openControlsPanel();
|
||
|
||
// Switch to terminated filter
|
||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("terminated").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
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("View Details").length).toBeGreaterThanOrEqual(4);
|
||
});
|
||
|
||
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("keeps clickable identity area behavior for opening detail view", async () => {
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
|
||
});
|
||
|
||
const clickableIdentity = Array.from(document.querySelectorAll(".agent-info--clickable")).find((element) =>
|
||
element.textContent?.includes("Test Agent 1"),
|
||
) as HTMLElement | undefined;
|
||
expect(clickableIdentity).toBeTruthy();
|
||
|
||
fireEvent.click(clickableIdentity!);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-001");
|
||
});
|
||
});
|
||
|
||
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("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("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: {},
|
||
},
|
||
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);
|
||
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);
|
||
});
|
||
|
||
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("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("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 terminated agents in default view", async () => {
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
|
||
await waitFor(() => {
|
||
const deleteButtons = screen.getAllByTitle("Delete");
|
||
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
await openControlsPanel();
|
||
|
||
// Switch to terminated filter
|
||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||
// Now we should see the Delete button for terminated agent
|
||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
it("does not show Delete button for active or paused agents", async () => {
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
|
||
await waitFor(() => {
|
||
// Find the active agent card (agent-002)
|
||
const allCards = Array.from(document.querySelectorAll(".agent-card"));
|
||
const activeCard = allCards.find((card) => card.textContent?.includes("agent-002")) ?? null;
|
||
const pausedCard = allCards.find((card) => card.textContent?.includes("agent-003")) ?? null;
|
||
|
||
// Active and paused agents should not have delete buttons
|
||
expect((activeCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
|
||
expect((pausedCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
|
||
});
|
||
});
|
||
|
||
it("confirms before deleting terminated agent (from terminated filter)", async () => {
|
||
mockConfirm.mockResolvedValueOnce(false);
|
||
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
await openControlsPanel();
|
||
|
||
// Switch to terminated filter to see terminated agent
|
||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||
// Click the delete button for the terminated agent (agent-004)
|
||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||
(card) => card.textContent?.includes("agent-004")
|
||
) ?? null;
|
||
const terminatedDeleteBtn = (terminatedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||
expect(terminatedDeleteBtn).toBeTruthy();
|
||
fireEvent.click(terminatedDeleteBtn);
|
||
});
|
||
|
||
expect(mockConfirm).toHaveBeenCalledWith({
|
||
title: "Delete Agent",
|
||
message: 'Delete agent "Test Agent 4"? This cannot be undone.',
|
||
danger: true,
|
||
});
|
||
expect(mockDeleteAgent).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("deletes terminated agent after confirmation (from terminated filter)", async () => {
|
||
|
||
render(<AgentsView addToast={mockAddToast} />);
|
||
await openControlsPanel();
|
||
|
||
// Switch to terminated filter to see terminated agent
|
||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("Test Agent 4").length).toBeGreaterThan(0);
|
||
});
|
||
|
||
// Find the delete button for terminated agent (agent-004)
|
||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||
(card) => card.textContent?.includes("agent-004")
|
||
) ?? null;
|
||
const terminatedDeleteBtn = (terminatedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||
fireEvent.click(terminatedDeleteBtn);
|
||
|
||
await waitFor(() => {
|
||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
|
||
});
|
||
|
||
expect(mockAddToast).toHaveBeenCalledWith(
|
||
expect.stringContaining("deleted"),
|
||
"success"
|
||
);
|
||
});
|
||
|
||
it("deletes idle agent after confirmation (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"
|
||
);
|
||
});
|
||
});
|
||
|
||
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 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 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 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 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 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 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 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();
|
||
});
|
||
});
|
||
});
|
||
});
|