feat(FN-861): replace advanced settings placeholder with editable form in AgentDetailView
- Replace advanced settings placeholder in AgentDetailView with full editable form supporting all setting fields - Remove color theme system (ThemeSelector component, useTheme hook, theme styles) - Simplify QuickEntryBox by extracting shared logic - Add comprehensive tests for AgentDetailView settings form including logLevel select field - Fix Settings saved indicator logic and update READMEs - Remove unused color theme types from core and revert color themes changeset
This commit is contained in:
@@ -104,7 +104,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
|
||||
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
|
||||
- **Agent CRUD**: Create agents with name and role (create form uses dashboard radius tokens for consistent styling), change state, update roles inline, delete terminated agents
|
||||
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated)
|
||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information
|
||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. The **Settings** tab now includes **editable advanced settings** (heartbeat interval, max retries, task timeout, log level) persisted through `agent.metadata`. Empty fields revert to system defaults, invalid values block save with inline error messages
|
||||
|
||||
### Interactive Terminal
|
||||
Access a fully functional PTY (pseudo-terminal) shell directly from the dashboard. Click the terminal icon in the header to open the interactive terminal modal.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ExternalLink, CheckCircle, XCircle, Loader2
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -342,6 +342,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
||||
{activeTab === "config" && (
|
||||
<ConfigTab
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1171,11 +1174,192 @@ function formatDuration(start: Date, end: Date): string {
|
||||
|
||||
// ── Config Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Shape of a single advanced setting field stored in agent.metadata */
|
||||
interface AdvancedSettingField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "select";
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
/** Minimum value for number fields */
|
||||
min?: number;
|
||||
/** Maximum value for number fields */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Well-known advanced setting definitions backed by agent.metadata */
|
||||
const ADVANCED_SETTINGS: AdvancedSettingField[] = [
|
||||
{
|
||||
key: "heartbeatIntervalMs",
|
||||
label: "Heartbeat Interval (ms)",
|
||||
type: "number",
|
||||
placeholder: "30000",
|
||||
hint: "How often the agent sends heartbeats (minimum 1000ms, default 30000ms)",
|
||||
min: 1000,
|
||||
max: 600000,
|
||||
},
|
||||
{
|
||||
key: "maxRetries",
|
||||
label: "Max Retries",
|
||||
type: "number",
|
||||
placeholder: "3",
|
||||
hint: "Maximum number of automatic retries on task failure (0–10, default 3)",
|
||||
min: 0,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
key: "timeoutMs",
|
||||
label: "Task Timeout (ms)",
|
||||
type: "number",
|
||||
placeholder: "600000",
|
||||
hint: "Maximum time in ms before a task is considered timed out (minimum 60000ms, default 600000ms)",
|
||||
min: 60000,
|
||||
max: 86400000,
|
||||
},
|
||||
{
|
||||
key: "logLevel",
|
||||
label: "Log Level",
|
||||
type: "select",
|
||||
hint: "Verbosity of agent log output",
|
||||
options: [
|
||||
{ value: "debug", label: "Debug" },
|
||||
{ value: "info", label: "Info" },
|
||||
{ value: "warn", label: "Warning" },
|
||||
{ value: "error", label: "Error" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Validation errors keyed by setting key */
|
||||
type ValidationErrors = Record<string, string>;
|
||||
|
||||
function validateAdvancedSettings(
|
||||
values: Record<string, string>,
|
||||
): ValidationErrors {
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
for (const field of ADVANCED_SETTINGS) {
|
||||
const raw = values[field.key]?.trim();
|
||||
|
||||
// Empty is fine — it means "use default"
|
||||
if (!raw) continue;
|
||||
|
||||
if (field.type === "number") {
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
errors[field.key] = `"${field.label}" must be a valid number`;
|
||||
continue;
|
||||
}
|
||||
if (field.min !== undefined && num < field.min) {
|
||||
errors[field.key] = `"${field.label}" must be at least ${field.min.toLocaleString()}`;
|
||||
}
|
||||
if (field.max !== undefined && num > field.max) {
|
||||
errors[field.key] = `"${field.label}" must be at most ${field.max.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
const validOptions = field.options?.map((o) => o.value) ?? [];
|
||||
if (validOptions.length > 0 && !validOptions.includes(raw)) {
|
||||
errors[field.key] = `"${field.label}" must be one of: ${validOptions.join(", ")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function ConfigTab({
|
||||
agent
|
||||
agent,
|
||||
projectId,
|
||||
addToast,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
// Local form state initialised from agent.metadata
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const field of ADVANCED_SETTINGS) {
|
||||
const raw = agent.metadata[field.key];
|
||||
if (raw !== undefined && raw !== null) {
|
||||
initial[field.key] = String(raw);
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
|
||||
/** Detect whether any local value differs from the persisted metadata */
|
||||
const hasChanges = (() => {
|
||||
for (const field of ADVANCED_SETTINGS) {
|
||||
const current = formValues[field.key]?.trim() ?? "";
|
||||
const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null
|
||||
? String(agent.metadata[field.key])
|
||||
: "";
|
||||
if (current !== persisted) return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
setJustSaved(false);
|
||||
// Clear individual field error on change
|
||||
if (errors[key]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate before save
|
||||
const validationErrors = validateAdvancedSettings(formValues);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
addToast("Please fix validation errors before saving", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the metadata payload — only include non-empty values
|
||||
const newMetadata: Record<string, unknown> = { ...agent.metadata };
|
||||
for (const field of ADVANCED_SETTINGS) {
|
||||
const raw = formValues[field.key]?.trim();
|
||||
if (!raw) {
|
||||
// Remove the key to use system default
|
||||
delete newMetadata[field.key];
|
||||
} else if (field.type === "number") {
|
||||
newMetadata[field.key] = Number(raw);
|
||||
} else {
|
||||
newMetadata[field.key] = raw;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateAgent(agent.id, { metadata: newMetadata }, projectId);
|
||||
addToast("Advanced settings saved", "success");
|
||||
setJustSaved(true);
|
||||
// Auto-hide the saved indicator after 3 seconds
|
||||
setTimeout(() => setJustSaved(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to save settings: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-tab">
|
||||
<div className="config-section">
|
||||
@@ -1214,15 +1398,75 @@ function ConfigTab({
|
||||
<div className="config-section">
|
||||
<h3>Advanced Settings</h3>
|
||||
<p className="config-description">
|
||||
Advanced configuration options for power users.
|
||||
Advanced configuration options for this agent. Leave a field empty to use system defaults.
|
||||
</p>
|
||||
|
||||
<div className="config-placeholder">
|
||||
<Settings size={32} opacity={0.3} />
|
||||
<p>Advanced configuration options will be available in a future update.</p>
|
||||
<p className="text-muted">
|
||||
This will include model selection, heartbeat intervals, and environment variables.
|
||||
</p>
|
||||
|
||||
<div className="config-fields">
|
||||
{ADVANCED_SETTINGS.map((field) => {
|
||||
const hasError = !!errors[field.key];
|
||||
return (
|
||||
<div className="config-field" key={field.key}>
|
||||
<label htmlFor={`adv-${field.key}`}>{field.label}</label>
|
||||
{field.type === "select" ? (
|
||||
<select
|
||||
id={`adv-${field.key}`}
|
||||
className={cn("select", hasError && "input--error")}
|
||||
value={formValues[field.key] ?? ""}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id={`adv-${field.key}`}
|
||||
type="text"
|
||||
inputMode={field.type === "number" ? "numeric" : undefined}
|
||||
className={cn("input", hasError && "input--error")}
|
||||
placeholder={field.placeholder}
|
||||
value={formValues[field.key] ?? ""}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{hasError && (
|
||||
<span className="config-error">{errors[field.key]}</span>
|
||||
)}
|
||||
{!hasError && field.hint && (
|
||||
<span className="config-hint">{field.hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
disabled={!hasChanges || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!hasChanges && justSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
Settings saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1274,17 +1518,30 @@ function ConfigTab({
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.config-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
.config-error {
|
||||
font-size: 11px;
|
||||
color: var(--error, #f85149);
|
||||
}
|
||||
|
||||
.config-placeholder p {
|
||||
margin: 8px 0 0 0;
|
||||
.input--error {
|
||||
border-color: var(--error, #f85149) !important;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-saved-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--success, #3fb950);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { AgentDetailView } from "../AgentDetailView";
|
||||
@@ -8,14 +8,16 @@ import type { AgentCapability, AgentDetail } from "../../api";
|
||||
// Mock the API functions
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
fetchAgentLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgentState } from "../../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
@@ -59,6 +61,7 @@ describe("AgentDetailView", () => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent());
|
||||
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -277,4 +280,439 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Live Run")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Advanced Settings (Config Tab) ────────────────────────────────────
|
||||
|
||||
describe("Advanced Settings", () => {
|
||||
const navigateToSettings = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
};
|
||||
|
||||
it("renders advanced settings form fields on Settings tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Log Level")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty fields when metadata has no advanced settings", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-fills fields from agent metadata", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: {
|
||||
heartbeatIntervalMs: 15000,
|
||||
maxRetries: 5,
|
||||
logLevel: "debug",
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("15000");
|
||||
|
||||
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
|
||||
expect(retriesInput.value).toBe("5");
|
||||
|
||||
const logLevelSelect = screen.getByLabelText("Log Level") as HTMLSelectElement;
|
||||
expect(logLevelSelect.value).toBe("debug");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Save Settings button disabled when no changes", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save Settings")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Save Settings when a field is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "15000");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save Settings")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for non-numeric input in number field", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Simulate setting a non-numeric value via React's internal value setter
|
||||
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
|
||||
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(heartbeatInput, 'abc');
|
||||
fireEvent.change(heartbeatInput);
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for number below minimum", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "500");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must be at least 1,000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for number above maximum", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const retriesInput = await screen.findByLabelText("Max Retries");
|
||||
|
||||
await user.clear(retriesInput);
|
||||
await user.type(retriesInput, "99");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must be at most 10/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls updateAgent with correct metadata on save", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "15000");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
{ metadata: expect.objectContaining({ heartbeatIntervalMs: 15000 }) },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith("Advanced settings saved", "success");
|
||||
});
|
||||
|
||||
it("forwards projectId to updateAgent", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
projectId="proj_456"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "20000");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({ metadata: expect.any(Object) }),
|
||||
"proj_456",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("re-fetches agent after successful save", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Initial fetch + save-triggered refetch
|
||||
const initialFetchCount = vi.mocked(fetchAgent).mock.calls.length;
|
||||
|
||||
const retriesInput = await screen.findByLabelText("Max Retries");
|
||||
await user.clear(retriesInput);
|
||||
await user.type(retriesInput, "7");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(fetchAgent).mock.calls.length).toBeGreaterThan(initialFetchCount);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast on save failure", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgent.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const retriesInput = await screen.findByLabelText("Max Retries");
|
||||
await user.clear(retriesInput);
|
||||
await user.type(retriesInput, "2");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to save settings"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for non-numeric input in number field", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Type "abc" directly into a text input
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "abc");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must be a valid number/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-fills and persists logLevel select field", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: { logLevel: "debug" },
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const logLevelSelect = await screen.findByLabelText("Log Level");
|
||||
expect((logLevelSelect as HTMLSelectElement).value).toBe("debug");
|
||||
});
|
||||
|
||||
it("clears metadata key when field is cleared to empty", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: { heartbeatIntervalMs: 30000 },
|
||||
}));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({
|
||||
metadata: expect.not.objectContaining({ heartbeatIntervalMs: expect.anything() }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("persists existing non-advanced metadata keys during save", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: { customKey: "preserved", heartbeatIntervalMs: 30000 },
|
||||
}));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "45000");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = mockUpdateAgent.mock.calls[0];
|
||||
const metadata = (call as any)[1].metadata;
|
||||
expect(metadata.customKey).toBe("preserved");
|
||||
expect(metadata.heartbeatIntervalMs).toBe(45000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user