feat(FN-1827): merge fusion/fn-1827
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { Activity, Server, Settings, Trash2 } from "lucide-react";
|
||||
import { Activity, Server, Settings, Shield, Trash2 } from "lucide-react";
|
||||
import type { NodeInfo, ProjectInfo } from "../api";
|
||||
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
@@ -13,6 +13,10 @@ export interface NodeCardProps {
|
||||
onRemove: (id: string) => void;
|
||||
isLoading?: boolean;
|
||||
syncStatus?: ComputedNodeSyncStatus;
|
||||
/** Auth credential sync state for this node. Only meaningful for remote nodes. */
|
||||
authSyncState?: "match" | "differs" | "not-synced";
|
||||
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
|
||||
authSyncProviders?: Record<string, "match" | "differs">;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
|
||||
@@ -22,6 +26,30 @@ const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string;
|
||||
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
|
||||
};
|
||||
|
||||
const AUTH_SYNC_COLORS: Record<string, string> = {
|
||||
match: "var(--success)",
|
||||
differs: "var(--warning)",
|
||||
"not-synced": "var(--text-muted)",
|
||||
};
|
||||
|
||||
function buildAuthTooltip(
|
||||
state: "match" | "differs" | "not-synced",
|
||||
providers?: Record<string, "match" | "differs">,
|
||||
): string {
|
||||
if (state === "match") return "Auth credentials match";
|
||||
if (state === "not-synced") return "Auth not synced";
|
||||
// state === "differs"
|
||||
if (providers && Object.keys(providers).length > 0) {
|
||||
const differing = Object.entries(providers)
|
||||
.filter(([, status]) => status === "differs")
|
||||
.map(([name]) => name);
|
||||
if (differing.length > 0) {
|
||||
return `Auth credentials differ: ${differing.join(", ")}`;
|
||||
}
|
||||
}
|
||||
return "Auth credentials differ";
|
||||
}
|
||||
|
||||
function truncateUrl(url: string, maxLength: number = 42): string {
|
||||
if (url.length <= maxLength) return url;
|
||||
return `${url.slice(0, maxLength - 3)}...`;
|
||||
@@ -53,6 +81,22 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
|
||||
if (prevSync.diffCount !== nextSync.diffCount) return false;
|
||||
}
|
||||
|
||||
// Compare auth sync state
|
||||
if (previous.authSyncState !== next.authSyncState) return false;
|
||||
// Shallow compare authSyncProviders
|
||||
const prevProviders = previous.authSyncProviders;
|
||||
const nextProviders = next.authSyncProviders;
|
||||
if (prevProviders === nextProviders) {
|
||||
// same ref or both undefined - equal
|
||||
} else if (!prevProviders || !nextProviders) {
|
||||
return false; // one defined, one not
|
||||
} else {
|
||||
const prevKeys = Object.keys(prevProviders);
|
||||
const nextKeys = Object.keys(nextProviders);
|
||||
if (prevKeys.length !== nextKeys.length) return false;
|
||||
if (prevKeys.some((k) => prevProviders[k] !== nextProviders[k])) return false;
|
||||
}
|
||||
|
||||
// Compare project counts using the canonical counting function
|
||||
const previousCount = getProjectCountForNode(previous.projects, prevNode);
|
||||
const nextCount = getProjectCountForNode(next.projects, nextNode);
|
||||
@@ -67,6 +111,8 @@ function NodeCardInner({
|
||||
onRemove,
|
||||
isLoading = false,
|
||||
syncStatus,
|
||||
authSyncState,
|
||||
authSyncProviders,
|
||||
}: NodeCardProps) {
|
||||
const [removeArmed, setRemoveArmed] = useState(false);
|
||||
const statusConfig = STATUS_CONFIG[node.status];
|
||||
@@ -133,6 +179,16 @@ function NodeCardInner({
|
||||
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
{node.type === "remote" && authSyncState && (
|
||||
<span
|
||||
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
|
||||
title={buildAuthTooltip(authSyncState, authSyncProviders)}
|
||||
aria-label={`Auth sync: ${authSyncState === "match" ? "credentials match" : authSyncState === "differs" ? "credentials differ" : "not synced"}`}
|
||||
style={{ color: AUTH_SYNC_COLORS[authSyncState] }}
|
||||
>
|
||||
<Shield size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ interface NodesViewProps {
|
||||
export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
|
||||
const { projects } = useProjects();
|
||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode } = useNodeSettingsSync();
|
||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
||||
|
||||
@@ -179,6 +179,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
onRemove={(id) => { void handleUnregister(id); }}
|
||||
isLoading={loading}
|
||||
syncStatus={nodeSyncStatus}
|
||||
authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined}
|
||||
authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
|
||||
* - authentication: OAuth provider status, login/logout (independent)
|
||||
* - appearance: Theme and color settings (global)
|
||||
* - notifications: ntfy.sh notification settings (global)
|
||||
* - node-sync: Settings sync between nodes (global)
|
||||
* - global-models: Default/fallback models and thinking level (global)
|
||||
* - project-models: Planning & validator models, model presets, and AI summarization (project)
|
||||
* - general: Task prefix configuration (project)
|
||||
@@ -59,6 +60,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
|
||||
{ id: "appearance", label: "Appearance", scope: "global" },
|
||||
{ id: "notifications", label: "Notifications", scope: "global" },
|
||||
{ id: "node-sync", label: "Node Sync", scope: "global" },
|
||||
{ id: "global-models", label: "Models", scope: "global" },
|
||||
{ id: "project-models", label: "Project Models", scope: "project" },
|
||||
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
|
||||
@@ -2463,6 +2465,77 @@ export function SettingsModal({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "node-sync":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Node Sync</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="settingsSyncEnabled"
|
||||
type="checkbox"
|
||||
checked={form.settingsSyncEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable automatic settings sync
|
||||
</label>
|
||||
<small>Automatically synchronize settings between this node and connected remote nodes</small>
|
||||
</div>
|
||||
{form.settingsSyncEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncAuth" className="checkbox-label">
|
||||
<input
|
||||
id="settingsSyncAuth"
|
||||
type="checkbox"
|
||||
checked={form.settingsSyncAuth || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Sync model auth credentials
|
||||
</label>
|
||||
<small>Include API keys and OAuth tokens in sync operations</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncInterval">Sync interval</label>
|
||||
<select
|
||||
id="settingsSyncInterval"
|
||||
className="select"
|
||||
value={form.settingsSyncInterval || 900000}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
|
||||
}
|
||||
>
|
||||
<option value={300000}>Every 5 minutes</option>
|
||||
<option value={900000}>Every 15 minutes</option>
|
||||
<option value={1800000}>Every 30 minutes</option>
|
||||
<option value={3600000}>Every 1 hour</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncConflictResolution">Conflict resolution</label>
|
||||
<select
|
||||
id="settingsSyncConflictResolution"
|
||||
className="select"
|
||||
value={form.settingsSyncConflictResolution || "last-write-wins"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote" }))
|
||||
}
|
||||
>
|
||||
<option value="last-write-wins">Last write wins</option>
|
||||
<option value="always-ask">Always ask</option>
|
||||
<option value="keep-local">Keep local</option>
|
||||
<option value="keep-remote">Keep remote</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "prompts":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("lucide-react", () => ({
|
||||
Activity: () => <span data-testid="activity-icon">activity</span>,
|
||||
Server: () => <span data-testid="server-icon">server</span>,
|
||||
Settings: () => <span data-testid="settings-icon">settings</span>,
|
||||
Shield: () => <span data-testid="shield-icon">shield</span>,
|
||||
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
||||
}));
|
||||
|
||||
@@ -541,4 +542,125 @@ describe("NodeCard", () => {
|
||||
expect(syncIndicator).toHaveAttribute("data-sync-state", "diff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth sync indicator", () => {
|
||||
it("renders auth indicator for remote node with match state", () => {
|
||||
const node = makeNode({
|
||||
id: "node-auth-match",
|
||||
name: "Auth Match Node",
|
||||
type: "remote",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
authSyncState="match"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("shield-icon")).toBeInTheDocument();
|
||||
const indicator = document.querySelector(".node-card__auth-indicator--match");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator?.getAttribute("aria-label")).toContain("credentials match");
|
||||
});
|
||||
|
||||
it("renders auth indicator with differs state and provider details in tooltip", () => {
|
||||
const node = makeNode({
|
||||
id: "node-auth-differs",
|
||||
name: "Auth Differs Node",
|
||||
type: "remote",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
authSyncState="differs"
|
||||
authSyncProviders={{ anthropic: "differs", openai: "match" }}
|
||||
/>
|
||||
);
|
||||
|
||||
const indicator = document.querySelector(".node-card__auth-indicator--differs");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator?.getAttribute("aria-label")).toContain("credentials differ");
|
||||
expect(indicator?.getAttribute("title")).toContain("anthropic");
|
||||
});
|
||||
|
||||
it("renders auth indicator with not-synced state", () => {
|
||||
const node = makeNode({
|
||||
id: "node-auth-not-synced",
|
||||
name: "Auth Not Synced Node",
|
||||
type: "remote",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
authSyncState="not-synced"
|
||||
/>
|
||||
);
|
||||
|
||||
const indicator = document.querySelector(".node-card__auth-indicator--not-synced");
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator?.getAttribute("aria-label")).toContain("not synced");
|
||||
expect(indicator?.getAttribute("title")).toBe("Auth not synced");
|
||||
});
|
||||
|
||||
it("does not render auth indicator when authSyncState is undefined", () => {
|
||||
const node = makeNode({
|
||||
id: "node-no-auth",
|
||||
name: "No Auth State Node",
|
||||
type: "remote",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("shield-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render auth indicator for local nodes even with authSyncState", () => {
|
||||
const node = makeNode({
|
||||
id: "node-local-auth",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(
|
||||
<NodeCard
|
||||
node={node}
|
||||
projects={[]}
|
||||
onHealthCheck={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
authSyncState="match"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("shield-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,8 @@ beforeEach(() => {
|
||||
pushSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
pullSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
|
||||
getAuthSyncState: vi.fn().mockReturnValue(undefined),
|
||||
getAuthProviders: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,6 +335,8 @@ describe("NodesView", () => {
|
||||
pushSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
pullSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
|
||||
getAuthSyncState: vi.fn().mockReturnValue(undefined),
|
||||
getAuthProviders: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
@@ -369,6 +373,8 @@ describe("NodesView", () => {
|
||||
pushSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
pullSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
|
||||
getAuthSyncState: vi.fn().mockReturnValue(undefined),
|
||||
getAuthProviders: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
@@ -404,6 +410,8 @@ describe("NodesView", () => {
|
||||
pushSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
pullSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
|
||||
getAuthSyncState: vi.fn().mockReturnValue(undefined),
|
||||
getAuthProviders: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
@@ -441,6 +449,8 @@ describe("NodesView", () => {
|
||||
pushSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
pullSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
syncAuth: vi.fn().mockResolvedValue({ success: true, syncedProviders: [] }),
|
||||
getAuthSyncState: vi.fn().mockReturnValue(undefined),
|
||||
getAuthProviders: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||
|
||||
@@ -32,6 +32,10 @@ const defaultSettings: Settings = {
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
showQuickChatFAB: false,
|
||||
settingsSyncEnabled: false,
|
||||
settingsSyncAuth: false,
|
||||
settingsSyncInterval: 900000,
|
||||
settingsSyncConflictResolution: "last-write-wins",
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -1976,7 +1980,7 @@ describe("SettingsModal", () => {
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
// 15 nav items (group headers are not nav items)
|
||||
expect(navItems.length).toBe(15);
|
||||
expect(navItems.length).toBe(16);
|
||||
|
||||
// Labels include scope icons (Globe for global, Folder for project)
|
||||
const labels = Array.from(navItems).map((el) => el.textContent?.trim());
|
||||
@@ -1984,6 +1988,7 @@ describe("SettingsModal", () => {
|
||||
"Authentication",
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Node Sync",
|
||||
"Models",
|
||||
"Project Models",
|
||||
"General",
|
||||
@@ -2402,6 +2407,111 @@ describe("SettingsModal", () => {
|
||||
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
// Node Sync section tests
|
||||
describe("Node Sync section", () => {
|
||||
it("Node Sync section renders with heading and toggle", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
expect(screen.getByText("Node Sync", { selector: "h4" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sub-fields are hidden when sync is disabled", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
settingsSyncEnabled: false,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
expect(screen.queryByLabelText("Sync interval")).toBeNull();
|
||||
expect(screen.queryByLabelText("Conflict resolution")).toBeNull();
|
||||
});
|
||||
|
||||
it("sub-fields are visible when sync is enabled", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
settingsSyncEnabled: true,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync model auth credentials")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync interval")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Conflict resolution")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggle enables sync and updates form state", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
const checkbox = screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect((screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("interval dropdown changes form value", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
settingsSyncEnabled: true,
|
||||
settingsSyncInterval: 900000,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
const select = screen.getByLabelText("Sync interval") as HTMLSelectElement;
|
||||
expect(select.value).toBe("900000");
|
||||
|
||||
fireEvent.change(select, { target: { value: "3600000" } });
|
||||
expect(select.value).toBe("3600000");
|
||||
});
|
||||
|
||||
it("conflict resolution dropdown changes form value", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
settingsSyncEnabled: true,
|
||||
settingsSyncConflictResolution: "last-write-wins",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
const select = screen.getByLabelText("Conflict resolution") as HTMLSelectElement;
|
||||
expect(select.value).toBe("last-write-wins");
|
||||
|
||||
fireEvent.change(select, { target: { value: "keep-local" } });
|
||||
expect(select.value).toBe("keep-local");
|
||||
});
|
||||
|
||||
it("save persists global settings with sync enabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
const checkbox = screen.getByLabelText("Enable automatic settings sync");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.settingsSyncEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Model filter tests with CustomModelDropdown
|
||||
it("renders filter input in Models section dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
Reference in New Issue
Block a user