feat(FN-4088): split AgentDetailView tests into seven focused suites with s

Refactors the monolithic `AgentDetailView.test.tsx` into seven focused test suites (core, advanced-settings, chain-of-command, editors, logs-tasks-runs, settings, skills-procedure) plus a shared test helpers module, extracting 5,793 lines from the original 5,406-line file.

Fusion-Task-Id: FN-4088
This commit is contained in:
Fusion
2026-05-12 04:03:20 -07:00
committed by gsxdsm
parent 638ce19af7
commit 029bd31f46
9 changed files with 5793 additions and 5406 deletions

View File

@@ -0,0 +1,483 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { loadAllAppCss } from "../../test/cssFixture";
import type { AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
import {
MOCK_SKILLS,
createMockAgent,
mockConfirm,
mockDeleteAgent,
mockFetchAgent,
mockFetchAgentBudgetStatus,
mockFetchAgentChildren,
mockFetchAgentLogsWithMeta,
mockFetchAgentMailbox,
mockFetchAgentMemoryFile,
mockFetchAgentMemoryFiles,
mockFetchAgentRunDetail,
mockFetchAgentRunLogs,
mockFetchAgentRuns,
mockFetchAgentTasks,
mockFetchAgents,
mockFetchChainOfCommand,
mockFetchCompanies,
mockFetchDiscoveredSkills,
mockFetchModels,
mockFetchPluginRuntimes,
mockFetchSkillContent,
mockFetchWorkspaceFileContent,
mockMarkMessageRead,
mockResetAgentBudget,
mockSaveAgentMemoryFile,
mockSaveWorkspaceFileContent,
mockStartAgentRun,
mockSubscribeSse,
mockUpdateAgent,
mockUpdateAgentInstructions,
mockUpdateAgentMemory,
mockUpdateAgentSoul,
mockUpdateAgentState,
mockUpdateGlobalSettings,
mockUpgradeAgentHeartbeatProcedure,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — chain of command", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
onChildClick={onChildClick}
/>,
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
});
mockFetchChainOfCommand.mockResolvedValue([{ id: "agent-001", name: "Test Agent" } as AgentDetail] as any);
rerender(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
});
});
it("shows loading state while fetching chain of command", async () => {
const resolvedChain = [{ id: "agent-001", name: "Test Agent" } as AgentDetail];
const resolveChainCalls: Array<(agents: AgentDetail[]) => void> = [];
mockFetchChainOfCommand.mockImplementation(
() =>
new Promise((resolve) => {
resolveChainCalls.push(resolve as (agents: AgentDetail[]) => void);
}) as any,
);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
});
// React Strict Mode and concurrent rendering can trigger extra effect passes.
// Make late calls auto-resolve so the loading state can settle deterministically.
mockFetchChainOfCommand.mockResolvedValue(resolvedChain as any);
await act(async () => {
// Allow any additional in-flight calls to register before resolving all pendings.
await Promise.resolve();
while (resolveChainCalls.length > 0) {
const resolve = resolveChainCalls.shift();
resolve?.(resolvedChain);
await Promise.resolve();
}
});
await waitFor(() => {
expect(screen.queryByText("Loading reporting chain...")).not.toBeInTheDocument();
});
});
});
it("displays agent ID in footer", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("agent-001")).toBeInTheDocument();
});
});
it("calls API with correct agentId", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", undefined);
});
});
it("displays health status indicator", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
});
it("opens directly to Runs tab and auto-expands the provided initial run", async () => {
const runId = "run-001";
mockFetchAgentRunLogs.mockResolvedValueOnce([
{
timestamp: "2024-01-01T00:00:00.000Z",
taskId: "agent-run",
text: "Run log line",
type: "text",
} as AgentLogEntry,
]);
mockFetchAgentRunDetail.mockResolvedValueOnce({
id: runId,
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
systemPrompt: "System prompt text",
} as AgentHeartbeatRun);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
initialTab="runs"
initialRunId={runId}
/>,
);
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", runId, undefined);
expect(mockFetchAgentRunDetail).toHaveBeenCalledWith("agent-001", runId, undefined);
});
await waitFor(() => {
expect(screen.getByText("Run log line")).toBeInTheDocument();
expect(screen.getByText("System Prompt")).toBeInTheDocument();
});
});
it("shows run error in modal and launches prefilled GitHub issue", async () => {
const runId = "run-error";
mockFetchAgentRuns.mockResolvedValueOnce([
{
id: runId,
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: "2024-01-01T00:01:00.000Z",
status: "failed",
} as AgentHeartbeatRun,
]);
mockFetchAgentRunLogs.mockResolvedValueOnce([]);
mockFetchAgentRunDetail.mockResolvedValueOnce({
id: runId,
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: "2024-01-01T00:01:00.000Z",
status: "failed",
stderrExcerpt: "fatal: exploded",
} as AgentHeartbeatRun);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
initialTab="runs"
initialRunId={runId}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open error details" })).toBeInTheDocument();
});
expect(screen.queryByText("fatal: exploded")).toBeNull();
expect(screen.queryByLabelText("Agent error details")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
expect(screen.getByLabelText("Agent error details")).toBeInTheDocument();
expect(screen.getByText("fatal: exploded")).toBeInTheDocument();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
fireEvent.click(screen.getByRole("link", { name: "Report on GitHub" }));
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/Runfusion/Fusion/issues/new?"),
"_blank",
"noopener,noreferrer",
);
expect(openSpy.mock.calls[0]?.[0]).toContain("run-error");
openSpy.mockRestore();
});
it("renders Run Now in header for active and idle states only", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
cleanup();
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ state: "idle", taskId: undefined }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
cleanup();
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ state: "running", taskId: undefined }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Run now for Test Agent" })).not.toBeInTheDocument();
});
});
it("starts run from header and refreshes runs without runs-tab Run Now", async () => {
const addToast = vi.fn();
const user = userEvent.setup();
mockFetchAgentRuns.mockResolvedValue([
{
id: "run-001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun,
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Run now for Test Agent" }));
await waitFor(() => {
expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined, {
source: "on_demand",
triggerDetail: "Triggered from dashboard",
});
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Heartbeat run started for Test Agent", "success");
});
await user.click(screen.getByText("Runs"));
const initialRunFetchCalls = mockFetchAgentRuns.mock.calls.length;
await waitFor(() => {
expect(initialRunFetchCalls).toBeGreaterThan(0);
});
await user.click(screen.getByRole("button", { name: "Run now for Test Agent" }));
await waitFor(() => {
expect(mockFetchAgentRuns.mock.calls.length).toBeGreaterThan(initialRunFetchCalls);
});
expect(screen.getAllByRole("button", { name: "Run now for Test Agent" })).toHaveLength(1);
});
it("auto-expands the active run when opened from running control context", async () => {
const activeRunId = "run-001";
mockFetchAgentRunLogs.mockResolvedValueOnce([
{
timestamp: "2024-01-01T00:00:00.000Z",
taskId: "agent-run",
text: "Active run log line",
type: "text",
} as AgentLogEntry,
]);
mockFetchAgentRunDetail.mockResolvedValueOnce({
id: activeRunId,
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
systemPrompt: "Active run system prompt",
} as AgentHeartbeatRun);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
initialTab="runs"
initialRunId={null}
preferActiveRun
/>,
);
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", activeRunId, undefined);
expect(mockFetchAgentRunDetail).toHaveBeenCalledWith("agent-001", activeRunId, undefined);
});
await waitFor(() => {
expect(screen.getByText("System Prompt")).toBeInTheDocument();
const viewer = screen.getByTestId("agent-log-viewer");
expect(viewer.textContent).toContain("Active run log line");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,844 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { loadAllAppCss } from "../../test/cssFixture";
import type { AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
import {
MOCK_SKILLS,
createMockAgent,
mockConfirm,
mockDeleteAgent,
mockFetchAgent,
mockFetchAgentBudgetStatus,
mockFetchAgentChildren,
mockFetchAgentLogsWithMeta,
mockFetchAgentMailbox,
mockFetchAgentMemoryFile,
mockFetchAgentMemoryFiles,
mockFetchAgentRunDetail,
mockFetchAgentRunLogs,
mockFetchAgentRuns,
mockFetchAgentTasks,
mockFetchAgents,
mockFetchChainOfCommand,
mockFetchCompanies,
mockFetchDiscoveredSkills,
mockFetchModels,
mockFetchPluginRuntimes,
mockFetchSkillContent,
mockFetchWorkspaceFileContent,
mockMarkMessageRead,
mockResetAgentBudget,
mockSaveAgentMemoryFile,
mockSaveWorkspaceFileContent,
mockStartAgentRun,
mockSubscribeSse,
mockUpdateAgent,
mockUpdateAgentInstructions,
mockUpdateAgentMemory,
mockUpdateAgentSoul,
mockUpdateAgentState,
mockUpdateGlobalSettings,
mockUpgradeAgentHeartbeatProcedure,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — editors", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
describe("Instructions Tab", () => {
const navigateToInstructions = async (user: ReturnType<typeof userEvent.setup>) => {
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
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();
const onMutationSuccess = 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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
onMutationSuccess={onMutationSuccess}
/>
);
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(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: false });
});
expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success");
});
it("disables Save Instructions button when no changes", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
projectId="proj_456"
onClose={vi.fn()}
addToast={addToast}
/>
);
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",
);
});
});
it("toggles between edit and preview mode for inline instructions", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsText: "# Test\n\nThis is a test.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Default: edit mode should be active - verify textarea is present
await waitFor(() => {
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
});
// Find and verify the toggle buttons exist
const previewBtn = screen.getByTestId("instructions-preview-toggle");
expect(previewBtn).toBeInTheDocument();
// Click Preview button
await user.click(previewBtn);
// After clicking, the textarea should be gone and preview should appear
await waitFor(() => {
expect(screen.queryByLabelText("Inline Instructions")).not.toBeInTheDocument();
});
// Check for markdown preview
const preview = document.querySelector(".markdown-body");
expect(preview).toBeInTheDocument();
// Click Edit button to go back
const editBtn = screen.getByTestId("instructions-edit-toggle");
await user.click(editBtn);
// Should be back in edit mode
await waitFor(() => {
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
});
});
it("renders markdown content in preview mode for inline instructions", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsText: "# Test Instructions\n\nThis is **bold** and this is _italic_.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Click Preview button
await user.click(screen.getByTestId("instructions-preview-toggle"));
await waitFor(() => {
// Should render markdown elements
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Test Instructions");
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
});
it("shows placeholder when inline instructions are empty in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Click Preview button when instructions are empty
await user.click(screen.getByTestId("instructions-preview-toggle"));
await waitFor(() => {
expect(screen.getByText("No inline instructions defined yet. Switch to Edit mode to add instructions.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode for inline instructions", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// Save button should be visible in edit mode
await waitFor(() => {
expect(screen.getByText("Save Instructions")).toBeInTheDocument();
});
// Click Preview button
await user.click(screen.getByTestId("instructions-preview-toggle"));
// Save button should be hidden
await waitFor(() => {
expect(screen.queryByText("Save Instructions")).not.toBeInTheDocument();
});
});
it("does not affect file path section when toggling inline instructions preview", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test-agent.md",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToInstructions(user);
// File path section should be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
// Toggle to preview mode
await user.click(screen.getByTestId("instructions-preview-toggle"));
// File path should still be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
// Toggle back to edit mode
await user.click(screen.getByTestId("instructions-edit-toggle"));
// File path should still be visible
await waitFor(() => {
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
});
});
});
// ── Soul Tab ────────────────────────────────────────────────────────────────
describe("Soul Tab", () => {
const navigateToSoul = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Soul")).toBeInTheDocument();
});
await user.click(screen.getByText("Soul"));
};
it("renders Soul tab with textarea by default", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
});
});
it("toggles between edit and preview mode", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
soul: "# Agent Soul\n\nThis agent is **helpful** and _creative_.",
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
// Default: edit mode
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
});
// Click Preview
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByLabelText("Agent Soul")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Agent Soul");
});
// Click Edit
await user.click(screen.getByText("Edit"));
await waitFor(() => {
expect(screen.getByLabelText("Agent Soul")).toBeInTheDocument();
});
});
it("shows placeholder when soul is empty in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.getByText("No soul defined yet. Switch to Edit mode to define the agent's personality.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSoul(user);
await waitFor(() => {
expect(screen.getByText("Save Soul")).toBeInTheDocument();
});
await user.click(screen.getByText("Preview"));
await waitFor(() => {
expect(screen.queryByText("Save Soul")).not.toBeInTheDocument();
});
});
it("calls updateAgentSoul when saving soul", async () => {
const addToast = vi.fn();
mockUpdateAgentSoul.mockResolvedValue({} as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSoul(user);
const textarea = await screen.findByLabelText("Agent Soul");
await user.clear(textarea);
await user.type(textarea, "This is the agent's new soul");
await user.click(screen.getByText("Save Soul"));
await waitFor(() => {
expect(mockUpdateAgentSoul).toHaveBeenCalledWith("agent-001", "This is the agent's new soul", undefined);
expect(addToast).toHaveBeenCalledWith("Soul saved", "success");
});
});
});
// ── Memory Tab ─────────────────────────────────────────────────────────────
describe("Memory Tab", () => {
const navigateToMemory = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Agent Memory")).toBeInTheDocument();
});
await user.click(screen.getByText("Agent Memory"));
};
it("renders Memory tab with textarea by default", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
});
});
it("toggles between edit and preview mode", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ memory: "# Agent Memory\n\n- Item 1\n- Item 2" }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => {
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Edit mode" }));
await waitFor(() => {
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
});
});
it("shows placeholder when memory is empty in preview mode", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => {
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
});
});
it("hides save button when in preview mode", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
expect(screen.getByText("Save Memory")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => expect(screen.queryByText("Save Memory")).not.toBeInTheDocument());
});
it("hides inline Edit button when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "This agent has memory." }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
});
});
it("can switch inline memory to preview mode when agent is running", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "Agent memory content" }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(screen.getByRole("button", { name: "Preview mode" }));
await waitFor(() => expect(document.querySelector(".markdown-body")).toBeInTheDocument());
});
it("renders memory file preview markdown and toggles back to edit", async () => {
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "# Heading\n\n- entry" } as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
await waitFor(() => {
expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
expect(screen.getByText("Heading")).toBeInTheDocument();
expect(screen.queryByText("Save Memory File")).not.toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Memory file edit mode" }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Select a memory file to view and edit its content...")).toBeInTheDocument();
});
});
it("shows memory file preview placeholder when selected file is empty", async () => {
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
await waitFor(() => {
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
});
});
it("hides memory file edit button and disables save button for running agents", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToMemory(user);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Memory file preview mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Memory file edit mode" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
});
});
it("calls updateAgentMemory when saving memory", async () => {
const addToast = vi.fn();
mockUpdateAgentMemory.mockResolvedValue({} as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
await navigateToMemory(user);
const textarea = await screen.findByLabelText("Agent Memory");
await user.clear(textarea);
await user.type(textarea, "This is the agent's new memory");
await user.click(screen.getByText("Save Memory"));
await waitFor(() => {
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
});
});
});
// ── Skills ─────────────────────────────────────────────────────────────────
});

View File

@@ -0,0 +1,543 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { loadAllAppCss } from "../../test/cssFixture";
import type { AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
import {
MOCK_SKILLS,
createMockAgent,
mockConfirm,
mockDeleteAgent,
mockFetchAgent,
mockFetchAgentBudgetStatus,
mockFetchAgentChildren,
mockFetchAgentLogsWithMeta,
mockFetchAgentMailbox,
mockFetchAgentMemoryFile,
mockFetchAgentMemoryFiles,
mockFetchAgentRunDetail,
mockFetchAgentRunLogs,
mockFetchAgentRuns,
mockFetchAgentTasks,
mockFetchAgents,
mockFetchChainOfCommand,
mockFetchCompanies,
mockFetchDiscoveredSkills,
mockFetchModels,
mockFetchPluginRuntimes,
mockFetchSkillContent,
mockFetchWorkspaceFileContent,
mockMarkMessageRead,
mockResetAgentBudget,
mockSaveAgentMemoryFile,
mockSaveWorkspaceFileContent,
mockStartAgentRun,
mockSubscribeSse,
mockUpdateAgent,
mockUpdateAgentInstructions,
mockUpdateAgentMemory,
mockUpdateAgentSoul,
mockUpdateAgentState,
mockUpdateGlobalSettings,
mockUpgradeAgentHeartbeatProcedure,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — logs, tasks, and runs", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
describe("Logs tab", () => {
it("loads latest run logs lazily for agents without a current task", async () => {
const latestRun = {
id: "run-1001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
taskId: undefined,
activeRun: latestRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "agent-run", text: "First entry", type: "text" },
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "agent-run", text: "Second entry", type: "text" },
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
expect(mockFetchAgentRuns).not.toHaveBeenCalled();
expect(mockFetchAgentRunLogs).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Logs"));
await waitFor(() => {
expect(mockFetchAgentRuns).toHaveBeenCalledWith("agent-001", 1, undefined);
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", "run-1001", undefined);
});
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
await waitFor(() => {
const viewer = screen.getByTestId("agent-log-viewer");
expect(viewer.textContent).toContain("First entry");
expect(viewer.textContent).toContain("Second entry");
});
});
it("renders log entries in chronological order (oldest first)", async () => {
const latestRun = {
id: "run-1002",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
taskId: undefined,
activeRun: latestRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "agent-run", text: "Oldest entry", type: "text" },
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "agent-run", text: "Middle entry", type: "text" },
{ timestamp: "2024-01-01T00:03:00.000Z", taskId: "agent-run", text: "Newest entry", type: "text" },
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Logs"));
await waitFor(() => {
expect(screen.getByText("Oldest entry")).toBeInTheDocument();
});
const viewerText = screen.getByTestId("agent-log-viewer").textContent ?? "";
expect(viewerText.indexOf("Oldest entry")).toBeLessThan(viewerText.indexOf("Middle entry"));
expect(viewerText.indexOf("Middle entry")).toBeLessThan(viewerText.indexOf("Newest entry"));
});
it("renders tool details collapsed by default", async () => {
const latestRun = {
id: "run-1003",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
taskId: undefined,
activeRun: latestRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue([
{
timestamp: "2024-01-01T00:00:00.000Z",
taskId: "agent-run",
type: "tool",
text: "ls -la packages/",
detail: "very long tool output",
},
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
fireEvent.click(await screen.findByText("Logs"));
await screen.findByText("ls -la packages/");
const toggle = await screen.findByTestId("tool-detail-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.getByTestId("agent-log-viewer")).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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await user.click(await screen.findByText("Tasks"));
await waitFor(() => {
expect(screen.getByText("No tasks assigned to this agent")).toBeInTheDocument();
});
});
});
// ── Advanced Settings (Config Tab) ────────────────────────────────────
describe("Runs Tab — click to show logs", () => {
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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("keeps the active run log stream subscribed across run-list polling", async () => {
const intervalCallbacks: Array<() => void> = [];
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: TimerHandler) => {
if (typeof callback === "function") {
intervalCallbacks.push(callback as () => void);
}
return 1 as ReturnType<typeof setInterval>;
}) as typeof setInterval);
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(((id?: ReturnType<typeof setInterval>) => {
void id;
}) as typeof clearInterval);
try {
const activeRun = {
id: "run-live-1",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
activeRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([activeRun]);
mockFetchAgentRunLogs.mockResolvedValue([]);
mockFetchAgentRunDetail.mockResolvedValue(activeRun);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
const activeRunButton = screen.getAllByRole("button").find(
(btn) => btn.getAttribute("aria-label")?.includes("run-live")
&& btn.getAttribute("aria-label")?.includes("active"),
);
expect(activeRunButton).toBeTruthy();
fireEvent.click(activeRunButton!);
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", "run-live-1", undefined);
});
const streamUrl = "/api/agents/agent-001/runs/run-live-1/logs/stream";
expect(
mockSubscribeSse.mock.calls.filter(([url]) => url === streamUrl),
).toHaveLength(1);
await act(async () => {
intervalCallbacks.forEach((callback) => callback());
await Promise.resolve();
});
await waitFor(() => {
expect(mockFetchAgentRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
expect(
mockSubscribeSse.mock.calls.filter(([url]) => url === streamUrl),
).toHaveLength(1);
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
}
});
it("fetches and displays logs when clicking a completed run", async () => {
const mockLogs: AgentLogEntry[] = [
{ 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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
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 ──────────────────────────────────────────────────────
});

View File

@@ -0,0 +1,693 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { loadAllAppCss } from "../../test/cssFixture";
import type { AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
import {
MOCK_SKILLS,
createMockAgent,
mockConfirm,
mockDeleteAgent,
mockFetchAgent,
mockFetchAgentBudgetStatus,
mockFetchAgentChildren,
mockFetchAgentLogsWithMeta,
mockFetchAgentMailbox,
mockFetchAgentMemoryFile,
mockFetchAgentMemoryFiles,
mockFetchAgentRunDetail,
mockFetchAgentRunLogs,
mockFetchAgentRuns,
mockFetchAgentTasks,
mockFetchAgents,
mockFetchChainOfCommand,
mockFetchCompanies,
mockFetchDiscoveredSkills,
mockFetchModels,
mockFetchPluginRuntimes,
mockFetchSkillContent,
mockFetchWorkspaceFileContent,
mockMarkMessageRead,
mockResetAgentBudget,
mockSaveAgentMemoryFile,
mockSaveWorkspaceFileContent,
mockStartAgentRun,
mockSubscribeSse,
mockUpdateAgent,
mockUpdateAgentInstructions,
mockUpdateAgentMemory,
mockUpdateAgentSoul,
mockUpdateAgentState,
mockUpdateGlobalSettings,
mockUpgradeAgentHeartbeatProcedure,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — budget settings and autosave", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
describe("Budget Settings", () => {
const navigateToSettings = async (user: ReturnType<typeof userEvent.setup>) => {
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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,
thresholdPercent: 0.8,
isOverBudget: false,
isOverThreshold: true,
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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: null,
thresholdPercent: null,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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,
thresholdPercent: 0.8,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
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,
thresholdPercent: 0.8,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
// Second call (after reset)
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: 50000,
usagePercent: 0,
thresholdPercent: 0.8,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: "2026-04-10T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
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("Config autosave", () => {
const openSettings = async (user: ReturnType<typeof userEvent.setup>) => {
const settingsTab = await screen.findByRole("button", { name: "Settings" });
await user.click(settingsTab);
await screen.findByText("Agent Configuration");
};
it("auto-saves after debounce without clicking Save Settings", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "45");
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledTimes(1);
}, { timeout: 3000 });
expect(mockUpdateAgent.mock.calls[0]?.[1]).toMatchObject({
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 45_000 }),
});
});
it("does not autosave while validation errors are present", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "abc");
await waitFor(() => {
expect(screen.getByText('"Heartbeat Interval" must be a valid number')).toBeInTheDocument();
});
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledTimes(0);
}, { timeout: 900 });
});
it("shows saving then saved indicator during autosave", async () => {
const initialAgent = createMockAgent();
const refreshedAgent = createMockAgent({
runtimeConfig: { ...(initialAgent.runtimeConfig ?? {}), heartbeatTimeoutMs: 90_000 },
updatedAt: "2024-01-01T00:10:00.000Z",
});
mockFetchAgent.mockReset();
mockFetchAgent.mockResolvedValueOnce(initialAgent).mockResolvedValue(refreshedAgent);
let resolveSave: (() => void) | null = null;
mockUpdateAgent.mockImplementationOnce(() => new Promise((resolve) => {
resolveSave = () => resolve(createMockAgent() as any);
}));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
const heartbeatInput = screen.getByLabelText("Heartbeat Timeout (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "90");
await waitFor(() => {
expect(screen.getByText("Saving changes…")).toBeInTheDocument();
}, { timeout: 3000 });
resolveSave?.();
await waitFor(() => {
expect(screen.getByText("All changes saved")).toBeInTheDocument();
});
});
it("debounces rapid edits into a single autosave using latest value", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "1");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "12");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "123");
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledTimes(1);
}, { timeout: 4000 });
expect(mockUpdateAgent.mock.calls[0]?.[1]).toMatchObject({
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 123_000 }),
});
});
});
});

View File

@@ -0,0 +1,400 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { loadAllAppCss } from "../../test/cssFixture";
import type { AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
import {
MOCK_SKILLS,
createMockAgent,
mockConfirm,
mockDeleteAgent,
mockFetchAgent,
mockFetchAgentBudgetStatus,
mockFetchAgentChildren,
mockFetchAgentLogsWithMeta,
mockFetchAgentMailbox,
mockFetchAgentMemoryFile,
mockFetchAgentMemoryFiles,
mockFetchAgentRunDetail,
mockFetchAgentRunLogs,
mockFetchAgentRuns,
mockFetchAgentTasks,
mockFetchAgents,
mockFetchChainOfCommand,
mockFetchCompanies,
mockFetchDiscoveredSkills,
mockFetchModels,
mockFetchPluginRuntimes,
mockFetchSkillContent,
mockFetchWorkspaceFileContent,
mockMarkMessageRead,
mockResetAgentBudget,
mockSaveAgentMemoryFile,
mockSaveWorkspaceFileContent,
mockStartAgentRun,
mockSubscribeSse,
mockUpdateAgent,
mockUpdateAgentInstructions,
mockUpdateAgentMemory,
mockUpdateAgentSoul,
mockUpdateAgentState,
mockUpdateGlobalSettings,
mockUpgradeAgentHeartbeatProcedure,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — skills and procedure", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
describe("Skills", () => {
it("renders skill badges in Dashboard tab when agent has skills", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1", "skill-2"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("skill-1")).toBeInTheDocument();
expect(screen.getByText("skill-2")).toBeInTheDocument();
});
const skillBadges = document.querySelectorAll(".dashboard-summary-skill-badge");
expect(skillBadges).toHaveLength(2);
});
it("loads and displays skill details when a dashboard skill badge is clicked", async () => {
const user = userEvent.setup();
const agentWithSkills = createMockAgent({
metadata: { skills: ["/Users/test/.agents/skills/fusion/SKILL.md"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
mockFetchSkillContent.mockResolvedValue({
name: "Fusion Skill",
skillMd: "# Fusion Skill",
files: [],
});
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
const badge = await screen.findByRole("button", { name: "View details for fusion" });
await user.click(badge);
await waitFor(() => {
expect(mockFetchSkillContent).toHaveBeenCalledWith("/Users/test/.agents/skills/fusion/SKILL.md", undefined);
expect(screen.getByText("# Fusion Skill")).toBeInTheDocument();
});
});
it("shows error state and supports retry when skill content loading fails", async () => {
const user = userEvent.setup();
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
mockFetchSkillContent
.mockRejectedValueOnce(new Error("Failed to load skill content"))
.mockResolvedValueOnce({ name: "Recovered", skillMd: "# Recovered", files: [] });
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await user.click(await screen.findByRole("button", { name: "View details for skill-1" }));
await waitFor(() => {
expect(screen.getByText("Failed to load skill content")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("# Recovered")).toBeInTheDocument();
});
});
it("shows fallback when skill content has no SKILL.md body", async () => {
const user = userEvent.setup();
const agentWithSkills = createMockAgent({ metadata: { skills: ["skill-1"] } });
mockFetchAgent.mockResolvedValue(agentWithSkills);
mockFetchSkillContent.mockResolvedValue({ name: "Test Skill", skillMd: "", files: [] });
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await user.click(await screen.findByRole("button", { name: "View details for skill-1" }));
await waitFor(() => {
expect(screen.getByText("(No SKILL.md found)")).toBeInTheDocument();
});
});
it("shows dash when agent has no skills in Dashboard tab", async () => {
const agentWithNoSkills = createMockAgent({
metadata: {},
});
mockFetchAgent.mockResolvedValue(agentWithNoSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Skills: —")).toBeInTheDocument();
});
});
it("shows SkillMultiselect in Config tab", async () => {
const user = userEvent.setup();
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
// Navigate to Settings tab
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Should show pre-selected skill
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
});
it("pre-fills skills from agent metadata in Config tab", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1", "skill-2"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Should have both skills pre-selected
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-2");
});
it("includes skills in metadata when saving Config tab", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
mockUpdateAgent.mockResolvedValue(createMockAgent({ metadata: { skills: ["skill-1", "new-skill"] } }) as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Add a skill
await user.click(screen.getByTestId("add-skill-test"));
// Save settings
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
skills: ["skill-1", "test-skill"],
}),
}),
undefined,
);
});
});
it("enables Save Settings when skills change", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: [] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Initially no changes
expect(screen.getByText("Save Settings")).toBeDisabled();
// Add a skill
await user.click(screen.getByTestId("add-skill-test"));
// Save button should now be enabled
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
});
describe("Heartbeat procedure file viewer", () => {
const openSettings = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
};
it("renders heartbeat markdown view action when heartbeatProcedurePath is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
expect(screen.getByRole("button", { name: "View Heartbeat Markdown" })).toBeInTheDocument();
});
it("fetches and displays heartbeat file content from project workspace", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "# Heartbeat\n\nDo checks", mtime: "2024-01-01T00:00:00.000Z", size: 20 });
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" projectId="proj-1" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/agent-001/HEARTBEAT.md", "proj-1");
});
expect(screen.getByLabelText("Heartbeat Procedure File")).toHaveValue("# Heartbeat\n\nDo checks");
});
it("shows load error feedback when heartbeat file fetch fails", async () => {
const addToast = vi.fn();
mockFetchAgent.mockResolvedValue(createMockAgent({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
}));
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("permission denied"));
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load heartbeat procedure file: permission denied", "error");
});
expect(screen.getByText("Failed to load file: permission denied")).toBeInTheDocument();
});
it("refreshes to upgraded heartbeat path and supports immediate viewing", async () => {
mockFetchAgent
.mockResolvedValueOnce(createMockAgent({ heartbeatProcedurePath: undefined }))
.mockResolvedValueOnce(createMockAgent({ heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md" }));
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "# Seeded", mtime: "2024-01-01T00:00:00.000Z", size: 8 });
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" projectId="proj-2" onClose={vi.fn()} addToast={vi.fn()} />);
await openSettings(user);
await user.click(screen.getByRole("button", { name: "Upgrade agent to default heartbeat procedure file" }));
await waitFor(() => {
expect(mockUpgradeAgentHeartbeatProcedure).toHaveBeenCalledWith("agent-001", "proj-2");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "View Heartbeat Markdown" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "View Heartbeat Markdown" }));
await waitFor(() => {
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/agent-001/HEARTBEAT.md", "proj-2");
});
});
});
});

View File

@@ -0,0 +1,336 @@
// Shared mocks/fixtures for AgentDetailView.*.test.tsx — see FN-4088
import { createElement } from "react";
import { vi } from "vitest";
import type { AgentCapability, AgentDetail } from "../../api";
type ApiModule = typeof import("../../api");
type SseModule = typeof import("../../sse-bus");
export const mockFetchAgent = vi.fn<ApiModule["fetchAgent"]>();
export const mockFetchAgents = vi.fn<ApiModule["fetchAgents"]>();
export const mockUpdateAgent = vi.fn<ApiModule["updateAgent"]>();
export const mockUpdateAgentState = vi.fn<ApiModule["updateAgentState"]>();
export const mockDeleteAgent = vi.fn<ApiModule["deleteAgent"]>();
export const mockFetchAgentChildren = vi.fn<ApiModule["fetchAgentChildren"]>();
export const mockFetchAgentRunLogs = vi.fn<ApiModule["fetchAgentRunLogs"]>();
export const mockFetchAgentRuns = vi.fn<ApiModule["fetchAgentRuns"]>();
export const mockFetchAgentRunDetail = vi.fn<ApiModule["fetchAgentRunDetail"]>();
export const mockFetchAgentTasks = vi.fn<ApiModule["fetchAgentTasks"]>();
export const mockFetchChainOfCommand = vi.fn<ApiModule["fetchChainOfCommand"]>();
export const mockFetchAgentBudgetStatus = vi.fn<ApiModule["fetchAgentBudgetStatus"]>();
export const mockResetAgentBudget = vi.fn<ApiModule["resetAgentBudget"]>();
export const mockUpdateAgentInstructions = vi.fn<ApiModule["updateAgentInstructions"]>();
export const mockUpdateAgentSoul = vi.fn<ApiModule["updateAgentSoul"]>();
export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>();
export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>();
export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>();
export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>();
export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>();
export const mockSaveWorkspaceFileContent = vi.fn<ApiModule["saveWorkspaceFileContent"]>();
export const mockFetchDiscoveredSkills = vi.fn<ApiModule["fetchDiscoveredSkills"]>();
export const mockFetchSkillContent = vi.fn<ApiModule["fetchSkillContent"]>();
export const mockFetchModels = vi.fn<ApiModule["fetchModels"]>();
export const mockFetchPluginRuntimes = vi.fn<ApiModule["fetchPluginRuntimes"]>();
export const mockFetchAgentLogsWithMeta = vi.fn<ApiModule["fetchAgentLogsWithMeta"]>();
export const mockFetchAgentMailbox = vi.fn<ApiModule["fetchAgentMailbox"]>();
export const mockMarkMessageRead = vi.fn<ApiModule["markMessageRead"]>();
export const mockStartAgentRun = vi.fn<ApiModule["startAgentRun"]>();
export const mockUpgradeAgentHeartbeatProcedure = vi.fn<ApiModule["upgradeAgentHeartbeatProcedure"]>();
export const mockUpdateGlobalSettings = vi.fn<ApiModule["updateGlobalSettings"]>();
export const mockFetchCompanies = vi.fn<ApiModule["fetchCompanies"]>();
export const mockSubscribeSse = vi.fn<SseModule["subscribeSse"]>();
export const mockConfirm = vi.fn();
export const MOCK_SKILLS = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
{ id: "skill-2", name: "Skill Two", path: "/path/skill-2", relativePath: "skills/skill-2", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
];
vi.mock("../../api", () => ({
fetchAgent: (...args: Parameters<ApiModule["fetchAgent"]>) => mockFetchAgent(...args),
fetchAgents: (...args: Parameters<ApiModule["fetchAgents"]>) => mockFetchAgents(...args),
updateAgent: (...args: Parameters<ApiModule["updateAgent"]>) => mockUpdateAgent(...args),
updateAgentState: (...args: Parameters<ApiModule["updateAgentState"]>) => mockUpdateAgentState(...args),
deleteAgent: (...args: Parameters<ApiModule["deleteAgent"]>) => mockDeleteAgent(...args),
fetchAgentLogs: vi.fn(),
fetchAgentLogsWithMeta: (...args: Parameters<ApiModule["fetchAgentLogsWithMeta"]>) => mockFetchAgentLogsWithMeta(...args),
fetchAgentMailbox: (...args: Parameters<ApiModule["fetchAgentMailbox"]>) => mockFetchAgentMailbox(...args),
markMessageRead: (...args: Parameters<ApiModule["markMessageRead"]>) => mockMarkMessageRead(...args),
fetchAgentRunLogs: (...args: Parameters<ApiModule["fetchAgentRunLogs"]>) => mockFetchAgentRunLogs(...args),
fetchAgentChildren: (...args: Parameters<ApiModule["fetchAgentChildren"]>) => mockFetchAgentChildren(...args),
fetchAgentRuns: (...args: Parameters<ApiModule["fetchAgentRuns"]>) => mockFetchAgentRuns(...args),
fetchAgentRunDetail: (...args: Parameters<ApiModule["fetchAgentRunDetail"]>) => mockFetchAgentRunDetail(...args),
startAgentRun: (...args: Parameters<ApiModule["startAgentRun"]>) => mockStartAgentRun(...args),
stopAgentRun: vi.fn(),
updateAgentInstructions: (...args: Parameters<ApiModule["updateAgentInstructions"]>) => mockUpdateAgentInstructions(...args),
updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args),
updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args),
fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args),
fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args),
saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...args),
fetchAgentTasks: (...args: Parameters<ApiModule["fetchAgentTasks"]>) => mockFetchAgentTasks(...args),
fetchChainOfCommand: (...args: Parameters<ApiModule["fetchChainOfCommand"]>) => mockFetchChainOfCommand(...args),
fetchAgentBudgetStatus: (...args: Parameters<ApiModule["fetchAgentBudgetStatus"]>) => mockFetchAgentBudgetStatus(...args),
resetAgentBudget: (...args: Parameters<ApiModule["resetAgentBudget"]>) => mockResetAgentBudget(...args),
fetchWorkspaceFileContent: (...args: Parameters<ApiModule["fetchWorkspaceFileContent"]>) => mockFetchWorkspaceFileContent(...args),
saveWorkspaceFileContent: (...args: Parameters<ApiModule["saveWorkspaceFileContent"]>) => mockSaveWorkspaceFileContent(...args),
fetchDiscoveredSkills: (...args: Parameters<ApiModule["fetchDiscoveredSkills"]>) => mockFetchDiscoveredSkills(...args),
fetchSkillContent: (...args: Parameters<ApiModule["fetchSkillContent"]>) => mockFetchSkillContent(...args),
fetchModels: (...args: Parameters<ApiModule["fetchModels"]>) => mockFetchModels(...args),
fetchPluginRuntimes: (...args: Parameters<ApiModule["fetchPluginRuntimes"]>) => mockFetchPluginRuntimes(...args),
upgradeAgentHeartbeatProcedure: (...args: Parameters<ApiModule["upgradeAgentHeartbeatProcedure"]>) => mockUpgradeAgentHeartbeatProcedure(...args),
updateGlobalSettings: (...args: Parameters<ApiModule["updateGlobalSettings"]>) => mockUpdateGlobalSettings(...args),
fetchCompanies: (...args: Parameters<ApiModule["fetchCompanies"]>) => mockFetchCompanies(...args),
uploadAgentAvatar: vi.fn(),
deleteAgentAvatar: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
AgentLogViewer: ({ entries }: { entries: Array<{ text: string; detail?: string }> }) => createElement(
"div",
{ "data-testid": "agent-log-viewer" },
...entries.map((e, i) => createElement(
"div",
{ key: i },
createElement("span", null, e.text),
e.detail
? createElement(
"button",
{ type: "button", "data-testid": "tool-detail-toggle", "aria-expanded": "false" },
"Show output",
)
: null,
)),
),
}));
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id, favoriteProviders = [], favoriteModels = [] }: {
models: Array<{ provider: string; id: string }>;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
label: string;
placeholder?: string;
id?: string;
favoriteProviders?: string[];
onToggleFavorite?: (provider: string) => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
}) => {
const selectId = id ?? "custom-model-dropdown";
return createElement(
"div",
{
"data-testid": "custom-model-dropdown",
"data-favorite-providers": favoriteProviders.join(","),
"data-favorite-models": favoriteModels.join(","),
},
createElement("label", { htmlFor: selectId }, label),
createElement(
"select",
{
id: selectId,
"aria-label": label,
value,
disabled,
onChange: (e: Event) => onChange((e.target as HTMLSelectElement).value),
},
createElement("option", { value: "" }, placeholder ?? "Use default"),
...models.map((model) => {
const modelValue = `${model.provider}/${model.id}`;
return createElement("option", { key: modelValue, value: modelValue }, modelValue);
}),
),
);
},
}));
vi.mock("../SkillMultiselect", () => ({
SkillMultiselect: ({ value, onChange, id: _id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => createElement(
"div",
{ "data-testid": "skill-multiselect" },
createElement("span", { "data-testid": "skill-multiselect-value" }, JSON.stringify(value)),
createElement("button", { "data-testid": "add-skill-test", onClick: () => onChange([...value, "test-skill"]) }, "Add Test Skill"),
createElement("button", { "data-testid": "remove-skill-test", onClick: () => onChange(value.filter((s) => s !== "test-skill")) }, "Remove Test Skill"),
),
}));
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
ExperimentalAgentOnboardingModal: ({ isOpen, mode, existingAgentConfig, onUseDraft, onClose }: {
isOpen: boolean;
mode?: "create" | "edit";
existingAgentConfig?: Record<string, unknown>;
onUseDraft: (summary: any) => void;
onClose: () => void;
}) => (isOpen
? createElement(
"div",
{ "data-testid": "mock-ai-interview-modal" },
createElement("span", { "data-testid": "mock-ai-interview-mode" }, mode),
createElement(
"button",
{
type: "button",
onClick: () => onUseDraft({
name: "Interviewed Agent",
role: "reviewer",
title: "Draft Title",
icon: "🧠",
reportsTo: "agent-002",
instructionsText: "Updated instructions",
soul: "Updated soul",
memory: "Updated memory",
skills: ["skill-1"],
thinkingLevel: "high",
maxTurns: 12,
model: "openai/gpt-4o",
}),
},
"Apply Draft",
),
createElement("button", { type: "button", onClick: onClose }, "Close Modal"),
createElement("pre", { "data-testid": "mock-ai-existing-config" }, JSON.stringify(existingAgentConfig ?? {})),
)
: null),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: Parameters<SseModule["subscribeSse"]>) => mockSubscribeSse(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
export const createMockAgent = (overrides: Partial<AgentDetail> = {}): 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;
export function setupAgentDetailMocks() {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockStartAgentRun.mockResolvedValue({ id: "run-003" } as any);
mockFetchAgents.mockResolvedValue([
{ id: "agent-001", name: "Test Agent", role: "executor", state: "active", metadata: {} },
{ id: "agent-002", name: "Manager Agent", role: "reviewer", state: "active", metadata: {} },
{ id: "agent-003", name: "Director Agent", role: "triage", state: "active", metadata: {} },
] as any);
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockDeleteAgent.mockResolvedValue(undefined);
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
mockFetchAgentRuns.mockResolvedValue([
...(mockAgent.activeRun ? [mockAgent.activeRun] : []),
...mockAgent.completedRuns,
]);
mockFetchAgentRunLogs.mockResolvedValue([]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 0,
messages: [],
inbox: [],
outbox: [],
});
mockMarkMessageRead.mockResolvedValue({
id: "msg-default",
fromId: "dashboard",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "",
type: "user-to-agent",
read: true,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any);
mockStartAgentRun.mockResolvedValue({ id: "run-003" } as any);
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: null,
usagePercent: null,
thresholdPercent: null,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
});
mockResetAgentBudget.mockResolvedValue(undefined);
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);
mockFetchAgentMemoryFiles.mockResolvedValue({
files: [
{
path: ".fusion/agent-memory/agent-001/MEMORY.md",
label: "MEMORY.md",
layer: "long-term",
size: 12,
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as any);
mockFetchAgentMemoryFile.mockResolvedValue({
path: ".fusion/agent-memory/agent-001/MEMORY.md",
content: "",
} as any);
mockSaveAgentMemoryFile.mockResolvedValue({ success: true } as any);
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
mockFetchModels.mockResolvedValue({
models: [
{ provider: "openai", id: "gpt-4o", name: "gpt-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-3-7-sonnet", name: "claude-3-7-sonnet", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
mockFetchPluginRuntimes.mockResolvedValue([
{ pluginId: "fusion-plugin-openclaw-runtime", runtimeId: "openclaw", name: "OpenClaw", description: "OpenClaw runtime", version: "1.0.0" },
{ pluginId: "fusion-plugin-hermes-runtime", runtimeId: "hermes", name: "Hermes", description: "Hermes runtime", version: "1.1.0" },
]);
mockUpgradeAgentHeartbeatProcedure.mockResolvedValue({
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
procedureFileSeeded: true,
});
mockUpdateGlobalSettings.mockResolvedValue({} as any);
mockFetchCompanies.mockResolvedValue({ companies: [] });
}