import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
import type { AgentCapability, AgentDetail } from "../../api";
// Mock the API functions
vi.mock("../../api", () => ({
fetchAgent: vi.fn(),
updateAgent: vi.fn(),
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
fetchAgentChildren: vi.fn(),
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(),
fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(),
fetchAgentBudgetStatus: vi.fn(),
resetAgentBudget: vi.fn(),
fetchWorkspaceFileContent: vi.fn(),
saveWorkspaceFileContent: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
AgentLogViewer: ({ entries }: { entries: Array<{ text: string }> }) => (
{entries.map((e, i) => {e.text})}
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
id: string;
name: string;
role: AgentCapability;
state: "idle" | "active" | "paused" | "terminated";
taskId?: string;
runtimeConfig?: Record;
}> = {}): AgentDetail => ({
id: "agent-001",
name: "Test Agent",
role: "executor" as AgentCapability,
state: "active",
taskId: "FN-001",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
lastHeartbeatAt: "2024-01-01T00:05:00.000Z",
metadata: {},
runtimeConfig: overrides.runtimeConfig,
heartbeatHistory: [],
activeRun: {
id: "run-001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
},
completedRuns: [
{
id: "run-002",
agentId: "agent-001",
startedAt: "2023-12-31T00:00:00.000Z",
endedAt: "2023-12-31T00:05:00.000Z",
status: "completed",
},
],
...overrides,
} as AgentDetail);
beforeEach(() => {
vi.clearAllMocks();
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
// Default: return runs from mock agent
mockFetchAgentRuns.mockResolvedValue([
...(mockAgent.activeRun ? [mockAgent.activeRun] : []),
...mockAgent.completedRuns,
]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
// Default: no budget limit configured
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: null,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: null,
nextResetAt: null,
});
mockResetAgentBudget.mockResolvedValue(undefined);
// Default: empty file content
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockUpdateAgentInstructions.mockResolvedValue({} as any);
});
it("shows loading state initially", () => {
render(
);
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
});
it("defines CSS variables for agent state tokens in the global stylesheet", async () => {
render(
);
await waitFor(() => {
const headings = screen.getAllByRole("heading", { level: 2 });
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
// Verify state CSS variables are defined in the global stylesheet (styles.css)
// (previously these were in inline style blocks, now they're in the global :root)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toContain("--state-idle-bg:");
expect(stylesContent).toContain("--state-active-bg:");
expect(stylesContent).toContain("--state-paused-bg:");
expect(stylesContent).toContain("--state-error-bg:");
expect(stylesContent).toContain("--state-idle-text:");
expect(stylesContent).toContain("--state-active-text:");
});
it("uses token-based state colors for badges instead of hardcoded hex", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Verify badge styles use CSS variable references for background, not hex values
const badges = document.querySelectorAll(".badge, .inline-badge");
badges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
// Background should use var(--state-*) references, not raw rgba() or hex
if (style.includes("background")) {
expect(style).toContain("var(--state-");
// Should not use raw rgba() for state backgrounds
expect(style).not.toMatch(/background:\s*rgba\(/);
}
});
});
it("uses token-based colors for health status instead of hardcoded hex", async () => {
render(
);
await waitFor(() => {
// The mock agent is active with a heartbeat from 2024, so it should show "Unresponsive"
const hasHealthStatus = screen.queryAllByText(/Healthy|Unresponsive|Idle/).length > 0;
expect(hasHealthStatus).toBe(true);
});
// Health badges in header should use var(--state-*) references, not raw hex
const headerBadges = document.querySelectorAll(".agent-detail-badges .badge");
headerBadges.forEach(badge => {
const htmlEl = badge as HTMLElement;
const style = htmlEl.getAttribute("style") ?? "";
if (style.includes("color:") && !style.includes("var(--state-")) {
// If the color is not a state variable, it should still be a CSS variable
expect(style).toMatch(/color:\s*var\(/);
}
});
});
it("uses token-based color references for success and error states", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Navigate to Runs tab to trigger rendering of run-related content
fireEvent.click(screen.getByText("Runs"));
// Verify that the global stylesheet defines --color-success and --color-error
// (previously checked in inline style blocks, now verified by reading styles.css)
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
expect(stylesContent).toMatch(/--color-success:/);
expect(stylesContent).toMatch(/--color-error:/);
});
it("uses global design tokens instead of component-local aliases", async () => {
render(
);
await waitFor(() => {
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
// Previously the component defined local aliases like --bg-primary, --accent, etc.
// Now these are replaced with direct global token references in the CSS classes.
// Verify the global stylesheet defines the real tokens that the component uses.
const fs = await import("fs");
const path = await import("path");
const stylesPath = path.join(__dirname, "../../styles.css");
const stylesContent = fs.readFileSync(stylesPath, "utf-8");
// The component classes now use --surface, --todo, --text, --card-hover directly
expect(stylesContent).toMatch(/--surface:/);
expect(stylesContent).toMatch(/--todo:/);
expect(stylesContent).toMatch(/--text:/);
expect(stylesContent).toMatch(/--card-hover:/);
});
it("displays agent name in header after loading", async () => {
render(
);
// Wait for the h2 element specifically (the header title)
await waitFor(() => {
const headings = screen.getAllByRole("heading", { level: 2 });
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
});
});
it("fetches the agent using the active project context", async () => {
render(
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", "proj_123");
});
});
it("does not refetch or show loading spinner when onClose/addToast callback identities change", async () => {
const initialOnClose = vi.fn();
const initialAddToast = vi.fn();
const { rerender } = render(
,
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument();
});
expect(mockFetchAgent).toHaveBeenCalledTimes(1);
rerender(
,
);
expect(mockFetchAgent).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Loading agent...")).not.toBeInTheDocument();
});
it("refreshes agent data without showing full-screen loading spinner after initial load", async () => {
const user = userEvent.setup();
let resolveRefresh: ((value: AgentDetail) => void) | undefined;
mockFetchAgent
.mockImplementationOnce(async () => createMockAgent())
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
render(
,
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument();
});
await user.click(screen.getByTitle("Refresh"));
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("Loading agent...")).not.toBeInTheDocument();
resolveRefresh?.(createMockAgent({ updatedAt: "2024-01-01T00:10:00.000Z" }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument();
});
});
it("displays role badge", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("executor")).toBeInTheDocument();
});
});
it("displays state badge", async () => {
render(
);
await waitFor(() => {
// There should be at least one element with "active" (could be in badge or inline-badge)
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
});
it("shows all tabs", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("Runs")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Employees")).toBeInTheDocument();
expect(screen.getByText("Soul")).toBeInTheDocument();
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(screen.getByText("Memory")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
});
it("renders Employees tab empty state", async () => {
const user = userEvent.setup();
mockFetchAgentChildren.mockResolvedValue([]);
render(
);
await user.click(await screen.findByText("Employees"));
await waitFor(() => {
expect(mockFetchAgentChildren).toHaveBeenCalledWith("agent-001", undefined);
expect(screen.getByText("No employees")).toBeInTheDocument();
expect(screen.getByText("This agent has no employees")).toBeInTheDocument();
});
});
it("shows Pause button for active agent", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Pause")).toBeInTheDocument();
});
});
it("shows Resume button for paused agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Resume")).toBeInTheDocument();
});
});
it("shows Delete button for terminated agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Start")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
});
});
it("shows Start button for terminated agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "active" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Start")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("Start"));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("shows Delete button for idle agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Delete")).toBeInTheDocument();
});
});
it("shows Pause and Stop buttons for running agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Pause")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument();
});
});
it("shows Retry and Stop buttons for error agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
render(
);
await waitFor(() => {
expect(screen.getByText("Retry")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument();
});
});
it("renders lifecycle controls in compact layout with agent-detail-controls container", async () => {
render(
);
await waitFor(() => {
// Verify lifecycle controls are in the compact controls container
const controlsContainer = document.querySelector(".agent-detail-controls");
expect(controlsContainer).toBeTruthy();
expect(controlsContainer?.querySelector(".btn--compact")).toBeTruthy();
});
});
it("renders utility actions in separate container", async () => {
render(
);
await waitFor(() => {
// Verify utility actions are in the separate utility container
const utilityContainer = document.querySelector(".agent-detail-utility-actions");
expect(utilityContainer).toBeTruthy();
// Refresh and Close buttons should be in utility container
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
});
});
it("shows statistics section on dashboard", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("Total Runs")).toBeInTheDocument();
});
});
describe("Chain of Command", () => {
it("renders chain-of-command section and displays agents in order", async () => {
mockFetchChainOfCommand.mockResolvedValue([
{ id: "agent-root", name: "CEO Agent" } as AgentDetail,
{ id: "agent-middle", name: "Director Agent" } as AgentDetail,
{ id: "agent-001", name: "Test Agent" } as AgentDetail,
] as any);
render(
,
);
await waitFor(() => {
expect(screen.getByText("Chain of Command")).toBeInTheDocument();
});
await waitFor(() => {
const nodes = Array.from(document.querySelectorAll(".chain-of-command-node"));
expect(nodes).toHaveLength(3);
expect(nodes.map((node) => node.textContent?.trim())).toEqual([
"CEO Agent",
"Director Agent",
"Test Agent",
]);
expect(nodes[2].className).toContain("chain-of-command-node--current");
});
});
it("navigates to ancestor agent when chain node is clicked", async () => {
const onChildClick = vi.fn();
mockFetchChainOfCommand.mockResolvedValue([
{ id: "agent-root", name: "CEO Agent" } as AgentDetail,
{ id: "agent-middle", name: "Director Agent" } as AgentDetail,
{ id: "agent-001", name: "Test Agent" } as AgentDetail,
] as any);
render(
,
);
await waitFor(() => {
expect(screen.getByText("CEO Agent")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "CEO Agent" }));
expect(onChildClick).toHaveBeenCalledWith("agent-root");
});
it("shows no reporting chain for empty or single-element chains", async () => {
mockFetchChainOfCommand.mockResolvedValue([]);
const { rerender } = render(
,
);
await waitFor(() => {
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
});
mockFetchChainOfCommand.mockResolvedValue([{ id: "agent-001", name: "Test Agent" } as AgentDetail] as any);
rerender(
,
);
await waitFor(() => {
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
});
});
it("shows loading state while fetching chain of command", async () => {
let resolveChain: ((agents: AgentDetail[]) => void) | undefined;
mockFetchChainOfCommand.mockImplementation(
() =>
new Promise((resolve) => {
resolveChain = resolve;
}) as any,
);
render(
,
);
await waitFor(() => {
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
});
resolveChain?.([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
await waitFor(() => {
expect(screen.queryByText("Loading reporting chain...")).not.toBeInTheDocument();
});
});
});
it("displays agent ID in footer", async () => {
render(
);
await waitFor(() => {
expect(screen.getByText("agent-001")).toBeInTheDocument();
});
});
it("calls API with correct agentId", async () => {
render(
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", undefined);
});
});
it("displays health status indicator", async () => {
render(
);
await waitFor(() => {
// Health status should be either Healthy, Unresponsive, or Idle
const healthTexts = ["Healthy", "Unresponsive", "Idle"];
const hasHealthStatus = healthTexts.some(text =>
document.body.textContent?.includes(text)
);
expect(hasHealthStatus).toBe(true);
});
});
it("shows Live Run on runs tab when agent has active run", async () => {
const user = userEvent.setup();
render(
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
});
describe("Tasks tab", () => {
it("renders tasks returned by fetchAgentTasks", async () => {
const user = userEvent.setup();
mockFetchAgentTasks.mockResolvedValue([
{
id: "FN-201",
title: "Implement assignment API",
description: "",
column: "in-progress",
status: "executing",
steps: [],
dependencies: [],
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
] as any);
render(
);
await user.click(await screen.findByText("Tasks"));
await waitFor(() => {
expect(mockFetchAgentTasks).toHaveBeenCalledWith("agent-001", undefined);
expect(screen.getByText("FN-201")).toBeInTheDocument();
expect(screen.getByText("Implement assignment API")).toBeInTheDocument();
});
});
it("shows empty state when no tasks are assigned", async () => {
const user = userEvent.setup();
mockFetchAgentTasks.mockResolvedValue([]);
render(
);
await user.click(await screen.findByText("Tasks"));
await waitFor(() => {
expect(screen.getByText("No tasks assigned to this agent")).toBeInTheDocument();
});
});
});
// ── Advanced Settings (Config Tab) ────────────────────────────────────
describe("Advanced Settings", () => {
const navigateToSettings = async (user: ReturnType) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders advanced settings form fields on Settings tab", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
// Heartbeat Settings section
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Heartbeat Timeout (ms)")).toBeInTheDocument();
// Advanced Settings section
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Log Level")).toBeInTheDocument();
});
});
it("shows empty fields when metadata and runtimeConfig are empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("");
});
});
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
heartbeatIntervalMs: 15000,
heartbeatTimeoutMs: 120000,
},
metadata: {
maxRetries: 5,
logLevel: "debug",
},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15000");
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (ms)") as HTMLInputElement;
expect(heartbeatTimeoutInput.value).toBe("120000");
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
expect(retriesInput.value).toBe("5");
const logLevelSelect = screen.getByLabelText("Log Level") as HTMLSelectElement;
expect(logLevelSelect.value).toBe("debug");
});
});
it("shows Save Settings button disabled when no changes", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Save Settings")).toBeDisabled();
});
});
it("enables Save Settings when a field is changed", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Simulate setting a non-numeric value via React's internal value setter
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
nativeInputValueSetter?.call(heartbeatInput, 'abc');
fireEvent.change(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("shows validation error for number below minimum", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (ms)");
await user.clear(heartbeatTimeoutInput);
await user.type(heartbeatTimeoutInput, "500");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at least 5,000/)).toBeInTheDocument();
});
});
it("shows validation error for number above maximum", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "99");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at most 10/)).toBeInTheDocument();
});
});
it("calls updateAgent with correct metadata and runtimeConfig on save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.any(Object),
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 15000 }),
}),
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Settings saved", "success");
});
it("forwards projectId to updateAgent", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "20000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ metadata: expect.any(Object) }),
"proj_456",
);
});
});
it("re-fetches agent after successful save", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Initial fetch + save-triggered refetch
const initialFetchCount = vi.mocked(fetchAgent).mock.calls.length;
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "7");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(vi.mocked(fetchAgent).mock.calls.length).toBeGreaterThan(initialFetchCount);
});
});
it("shows error toast on save failure", async () => {
const addToast = vi.fn();
mockUpdateAgent.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const retriesInput = await screen.findByLabelText("Max Retries");
await user.clear(retriesInput);
await user.type(retriesInput, "2");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to save settings"),
"error",
);
});
});
it("shows validation error for non-numeric input in number field", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Type "abc" directly into a text input
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "abc");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
});
});
it("pre-fills and persists logLevel select field", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { logLevel: "debug" },
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const logLevelSelect = await screen.findByLabelText("Log Level");
expect((logLevelSelect as HTMLSelectElement).value).toBe("debug");
});
it("clears runtimeConfig key when heartbeat field is cleared to empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: { heartbeatIntervalMs: 30000 },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
await user.clear(heartbeatInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
}),
undefined,
);
});
});
it("persists existing non-advanced metadata keys and runtimeConfig during save", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: { customKey: "preserved" },
runtimeConfig: { heartbeatIntervalMs: 30000, otherConfig: "also-preserved" },
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "45000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const payload = (call as any)[1];
expect(payload.metadata.customKey).toBe("preserved");
expect(payload.runtimeConfig.heartbeatIntervalMs).toBe(45000);
expect(payload.runtimeConfig.otherConfig).toBe("also-preserved");
});
});
});
// ── Budget Settings ──────────────────────────────────────────────────────
describe("Budget Settings", () => {
const navigateToSettings = async (user: ReturnType) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders Budget Settings section with all fields", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByLabelText("Token Budget")).toBeInTheDocument();
expect(screen.getByLabelText("Usage Threshold (%)")).toBeInTheDocument();
expect(screen.getByLabelText("Budget Period")).toBeInTheDocument();
expect(screen.getByLabelText("Reset Day")).toBeInTheDocument();
});
});
it("pre-fills budget fields from existing runtimeConfig.budgetConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000000,
usageThreshold: 0.8, // fraction stored, should display as 80%
budgetPeriod: "monthly",
resetDay: 15,
},
},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const tokenBudgetInput = screen.getByLabelText("Token Budget") as HTMLInputElement;
expect(tokenBudgetInput.value).toBe("1000000");
const thresholdInput = screen.getByLabelText("Usage Threshold (%)") as HTMLInputElement;
expect(thresholdInput.value).toBe("80"); // Converted from 0.8 to 80
const periodSelect = screen.getByLabelText("Budget Period") as HTMLSelectElement;
expect(periodSelect.value).toBe("monthly");
const resetDayInput = screen.getByLabelText("Reset Day") as HTMLInputElement;
expect(resetDayInput.value).toBe("15");
});
});
it("shows empty fields when budgetConfig is not set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
const tokenBudgetInput = screen.getByLabelText("Token Budget") as HTMLInputElement;
expect(tokenBudgetInput.value).toBe("");
const thresholdInput = screen.getByLabelText("Usage Threshold (%)") as HTMLInputElement;
expect(thresholdInput.value).toBe("");
const periodSelect = screen.getByLabelText("Budget Period") as HTMLSelectElement;
expect(periodSelect.value).toBe("");
});
});
it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
await user.type(tokenBudgetInput, "500000");
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
await user.clear(thresholdInput);
await user.type(thresholdInput, "75");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({
budgetConfig: {
tokenBudget: 500000,
usageThreshold: 0.75, // Converted from 75% to 0.75 fraction
},
}),
}),
undefined,
);
});
});
it("converts usage threshold percentage to fraction when saving", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
await user.clear(thresholdInput);
await user.type(thresholdInput, "90");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const payload = (call as any)[1];
expect(payload.runtimeConfig.budgetConfig.usageThreshold).toBe(0.9);
});
});
it("removes budgetConfig when all budget fields are cleared", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000000,
usageThreshold: 0.8,
},
heartbeatIntervalMs: 30000,
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Clear all budget fields
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
await user.clear(thresholdInput);
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.not.objectContaining({ budgetConfig: expect.anything() }),
}),
undefined,
);
});
});
it("preserves unrelated runtimeConfig keys when saving budget config", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
heartbeatIntervalMs: 30000,
heartbeatTimeoutMs: 60000,
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
await user.type(tokenBudgetInput, "200000");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
const call = mockUpdateAgent.mock.calls[0];
const payload = (call as any)[1];
expect(payload.runtimeConfig.heartbeatIntervalMs).toBe(30000);
expect(payload.runtimeConfig.heartbeatTimeoutMs).toBe(60000);
expect(payload.runtimeConfig.budgetConfig.tokenBudget).toBe(200000);
});
});
it("shows validation error for non-numeric token budget", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
await user.type(tokenBudgetInput, "abc");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/Token Budget.*must be a valid number/)).toBeInTheDocument();
});
});
it("shows validation error for token budget <= 0", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
await user.type(tokenBudgetInput, "0");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/Token Budget.*must be greater than 0/)).toBeInTheDocument();
});
});
it("shows validation error for usage threshold outside 1-100 range", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
await user.clear(thresholdInput);
await user.type(thresholdInput, "150");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/Usage Threshold.*must be between 1 and 100/)).toBeInTheDocument();
});
});
it("shows validation error for invalid reset day with weekly period", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
budgetConfig: {
budgetPeriod: "weekly",
},
},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Change period to weekly
const periodSelect = await screen.findByLabelText("Budget Period");
await user.selectOptions(periodSelect, "weekly");
const resetDayInput = await screen.findByLabelText("Reset Day");
await user.clear(resetDayInput);
await user.type(resetDayInput, "7"); // Invalid: 7 is not in 0-6 range
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/Reset Day.*must be between 0.*6.*for weekly/)).toBeInTheDocument();
});
});
it("shows validation error for invalid reset day with monthly period", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
budgetConfig: {
budgetPeriod: "monthly",
},
},
}));
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
// Change period to monthly
const periodSelect = await screen.findByLabelText("Budget Period");
await user.selectOptions(periodSelect, "monthly");
const resetDayInput = await screen.findByLabelText("Reset Day");
await user.clear(resetDayInput);
await user.type(resetDayInput, "32"); // Invalid: 32 is not in 1-31 range
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/Reset Day.*must be between 1 and 31.*for monthly/)).toBeInTheDocument();
});
});
it("enables Save Settings button when budget field is changed", async () => {
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
await user.clear(tokenBudgetInput);
await user.type(tokenBudgetInput, "100000");
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
it("shows budget progress bar when budget status has limit configured", async () => {
// Need to mock twice: once for DashboardTab and once for ConfigTab
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 40000,
budgetLimit: 50000,
usagePercent: 80,
isOverBudget: false,
isOverThreshold: true,
budgetPeriod: "monthly",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("40,000 / 50,000 tokens (80% used)")).toBeInTheDocument();
});
});
it("hides progress bar when no budget limit is configured", async () => {
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 10000,
budgetLimit: null,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: null,
nextResetAt: null,
});
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
// Progress bar should not be visible
expect(screen.queryByText(/tokens/)).not.toBeInTheDocument();
});
});
it("shows Reset Budget button when budget limit is configured", async () => {
// Need to mock twice: once for DashboardTab and once for ConfigTab
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 30000,
budgetLimit: 50000,
usagePercent: 60,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
});
});
it("calls resetAgentBudget when Reset Budget button is clicked", async () => {
const addToast = vi.fn();
// First call (ConfigTab on mount)
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 30000,
budgetLimit: 50000,
usagePercent: 60,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
// Second call (after reset)
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: 50000,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-04-10T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
});
await user.click(screen.getByText("Reset Budget Usage"));
await waitFor(() => {
expect(mockResetAgentBudget).toHaveBeenCalledWith("agent-001", undefined);
expect(addToast).toHaveBeenCalledWith("Budget usage reset successfully", "success");
});
});
});
// ── Runs Tab — Click to show logs ──────────────────────────────────
describe("Runs Tab — click to show logs", () => {
const navigateToRuns = async (user: ReturnType) => {
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
};
it("shows run cards as clickable with chevron indicators", async () => {
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
await waitFor(() => {
// Completed run card should be clickable (has role="button")
const buttons = screen.getAllByRole("button");
const runButtons = buttons.filter(btn => btn.getAttribute("aria-label")?.includes("run"));
expect(runButtons.length).toBeGreaterThan(0);
});
});
it("fetches and displays logs when clicking a completed run", async () => {
const mockLogs = [
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Starting task execution", type: "text" },
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "FN-001", text: "Read file: src/index.ts", type: "tool" },
];
mockFetchAgentRunLogs.mockResolvedValue(mockLogs);
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
// Wait for run cards to render
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
// Click the completed run
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Verify fetchAgentRunLogs was called
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalled();
});
// Verify logs appear
await waitFor(() => {
expect(screen.getByText("Starting task execution")).toBeInTheDocument();
});
});
it("shows loading state while fetching run logs", async () => {
// Create a promise that won't resolve immediately
let resolveLogs: (value: any) => void;
mockFetchAgentRunLogs.mockImplementation(() => new Promise(r => { resolveLogs = r; }));
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Should show loading state
await waitFor(() => {
expect(screen.getByText("Loading logs...")).toBeInTheDocument();
});
// Resolve to clean up
resolveLogs!([]);
});
it("shows empty message when no logs available for a run", async () => {
mockFetchAgentRunLogs.mockResolvedValue([]);
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("No logs available for this run")).toBeInTheDocument();
});
});
it("collapses log viewer when clicking the same run again", async () => {
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Test log entry", type: "text" },
]);
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
// Click to expand
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("Test log entry")).toBeInTheDocument();
});
// Click to collapse
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.queryByText("Test log entry")).not.toBeInTheDocument();
});
});
it("shows toast on fetch error", async () => {
const addToast = vi.fn();
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
mockFetchAgentRunDetail.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load run details"),
"error",
);
});
});
});
// ── Instructions Tab ──────────────────────────────────────────────────────
describe("Instructions Tab", () => {
const navigateToInstructions = async (user: ReturnType) => {
await waitFor(() => {
expect(screen.getByText("Instructions")).toBeInTheDocument();
});
await user.click(screen.getByText("Instructions"));
};
it("renders Instructions tab with inline instructions and path fields", async () => {
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
});
it("does not show file editor when instructions path is empty", async () => {
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.queryByLabelText("File Content")).not.toBeInTheDocument();
});
});
it("shows file editor when instructions path is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.getByLabelText("File Content")).toBeInTheDocument();
});
});
it("calls fetchWorkspaceFileContent when instructions path is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/test-agent.md");
});
});
it("shows file content when fetchWorkspaceFileContent succeeds", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "# Test Agent Instructions\n\nThese are the agent instructions.",
mtime: "2024-01-01T00:00:00.000Z",
size: 60,
});
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.getByLabelText("File Content")).toHaveValue("# Test Agent Instructions\n\nThese are the agent instructions.");
});
});
it("shows error toast when fetchWorkspaceFileContent fails with non-ENOENT error", async () => {
const addToast = vi.fn();
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("Permission denied"));
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load instructions file"),
"error",
);
});
});
it("treats ENOENT as empty file (new file state)", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/new-agent.md",
}));
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("ENOENT: file not found"));
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
// Should show empty content (new file state), not show error toast
const fileContent = screen.getByLabelText("File Content") as HTMLTextAreaElement;
expect(fileContent.value).toBe("");
});
});
it("calls updateAgentInstructions with expected payload when saving inline instructions", async () => {
const addToast = vi.fn();
mockUpdateAgentInstructions.mockResolvedValue({} as any);
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
const instructionsTextarea = await screen.findByLabelText("Inline Instructions");
await user.clear(instructionsTextarea);
await user.type(instructionsTextarea, "Custom instructions for the agent");
const pathInput = await screen.findByLabelText("Instructions File Path");
await user.clear(pathInput);
await user.type(pathInput, ".fusion/agents/test.md");
await user.click(screen.getByText("Save Instructions"));
await waitFor(() => {
expect(mockUpdateAgentInstructions).toHaveBeenCalledWith(
"agent-001",
{
instructionsText: "Custom instructions for the agent",
instructionsPath: ".fusion/agents/test.md",
},
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Instructions saved", "success");
});
it("calls saveWorkspaceFileContent when saving file content", async () => {
const addToast = vi.fn();
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "Original content",
mtime: "2024-01-01T00:00:00.000Z",
size: 16,
});
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
// Wait for file content to load
await waitFor(() => {
expect(screen.getByLabelText("File Content")).toHaveValue("Original content");
});
// Modify file content
const fileContent = screen.getByLabelText("File Content");
await user.clear(fileContent);
await user.type(fileContent, "Updated content");
// Save file
await user.click(screen.getByText("Save File"));
await waitFor(() => {
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith(
"project",
".fusion/agents/test.md",
"Updated content",
);
});
expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success");
});
it("disables Save Instructions button when no changes", async () => {
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.getByText("Save Instructions")).toBeDisabled();
});
});
it("disables Save File button when file content is not dirty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "Original content",
mtime: "2024-01-01T00:00:00.000Z",
size: 16,
});
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
await waitFor(() => {
expect(screen.getByText("Save File")).toBeDisabled();
});
});
it("shows Unsaved changes indicator when file content is dirty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({
content: "Original content",
mtime: "2024-01-01T00:00:00.000Z",
size: 16,
});
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
// Wait for file content to load
await waitFor(() => {
expect(screen.getByLabelText("File Content")).toHaveValue("Original content");
});
// Modify file content
const fileContent = screen.getByLabelText("File Content");
await user.clear(fileContent);
await user.type(fileContent, "Modified content");
await waitFor(() => {
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
});
});
it("forwards projectId to updateAgentInstructions", async () => {
const addToast = vi.fn();
const user = userEvent.setup();
render(
);
await navigateToInstructions(user);
const instructionsTextarea = await screen.findByLabelText("Inline Instructions");
await user.clear(instructionsTextarea);
await user.type(instructionsTextarea, "Custom instructions");
await user.click(screen.getByText("Save Instructions"));
await waitFor(() => {
expect(mockUpdateAgentInstructions).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
instructionsText: "Custom instructions",
}),
"proj_456",
);
});
});
});
});