feat(FN-1826): merge fusion/fn-1826
This commit is contained in:
@@ -12,6 +12,7 @@ vi.mock("lucide-react", () => ({
|
||||
Shield: () => <span data-testid="shield-icon">shield</span>,
|
||||
Upload: () => <span data-testid="upload-icon">upload</span>,
|
||||
X: () => <span data-testid="x-icon">x</span>,
|
||||
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SettingsSyncConflictModal } from "../SettingsSyncConflictModal";
|
||||
|
||||
// Define the props type inline to avoid import issues
|
||||
interface TestConflictEntry {
|
||||
key: string;
|
||||
localValue: unknown;
|
||||
remoteValue: unknown;
|
||||
}
|
||||
|
||||
interface TestProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onResolve: (resolutions: Array<{ key: string; value: unknown }>) => Promise<void>;
|
||||
conflicts: TestConflictEntry[];
|
||||
localNodeName: string;
|
||||
remoteNodeName: string;
|
||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<TestProps> = {}): TestProps {
|
||||
return {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onResolve: vi.fn().mockResolvedValue(undefined),
|
||||
conflicts: [
|
||||
{ key: "maxConcurrent", localValue: 2, remoteValue: 4 },
|
||||
{ key: "defaultModelId", localValue: "claude-sonnet", remoteValue: "gpt-4o" },
|
||||
],
|
||||
localNodeName: "Local Node",
|
||||
remoteNodeName: "Remote Node",
|
||||
addToast: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsSyncConflictModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("basic rendering", () => {
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps({ isOpen: false })} />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when conflicts array is empty", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps({ conflicts: [] })} />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all conflicts with key names", () => {
|
||||
const conflicts = [
|
||||
{ key: "setting1", localValue: 1, remoteValue: 2 },
|
||||
{ key: "setting2", localValue: "a", remoteValue: "b" },
|
||||
{ key: "setting3", localValue: true, remoteValue: false },
|
||||
];
|
||||
render(<SettingsSyncConflictModal {...makeProps({ conflicts })} />);
|
||||
|
||||
expect(screen.getByText("setting1")).toBeInTheDocument();
|
||||
expect(screen.getByText("setting2")).toBeInTheDocument();
|
||||
expect(screen.getByText("setting3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows side-by-side diff panels", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
// There are multiple "Local Node" and "Remote Node" labels (one per conflict)
|
||||
const localLabels = document.querySelectorAll(".settings-sync-conflict-modal__diff-label");
|
||||
expect(localLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("default resolution is Keep Local", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
const keepLocalRadios = screen.getAllByRole("radio", { name: "Keep Local" });
|
||||
expect(keepLocalRadios.length).toBeGreaterThan(0);
|
||||
expect(keepLocalRadios[0]).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution interactions", () => {
|
||||
it("selecting Keep Remote updates resolution", async () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
const keepRemoteRadios = screen.getAllByRole("radio", { name: "Keep Remote" });
|
||||
fireEvent.click(keepRemoteRadios[0]);
|
||||
expect(keepRemoteRadios[0]).toBeChecked();
|
||||
});
|
||||
|
||||
it("Merge Manually shows textarea", async () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
const mergeManuallyRadios = screen.getAllByRole("radio", { name: "Merge Manually" });
|
||||
fireEvent.click(mergeManuallyRadios[0]);
|
||||
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
expect(textareas.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Resolve All: Keep Local sets all to local", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
fireEvent.click(screen.getByText("Resolve All: Keep Local"));
|
||||
|
||||
const keepLocalRadios = screen.getAllByRole("radio", { name: "Keep Local" });
|
||||
for (const radio of keepLocalRadios) {
|
||||
expect(radio).toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
it("Resolve All: Keep Remote sets all to remote", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
fireEvent.click(screen.getByText("Resolve All: Keep Remote"));
|
||||
|
||||
const keepRemoteRadios = screen.getAllByRole("radio", { name: "Keep Remote" });
|
||||
for (const radio of keepRemoteRadios) {
|
||||
expect(radio).toBeChecked();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm/cancel actions", () => {
|
||||
it("Confirm calls onResolve with correct payload", async () => {
|
||||
const onResolve = vi.fn().mockResolvedValue(undefined);
|
||||
render(<SettingsSyncConflictModal {...makeProps({ onResolve })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
const resolutions = onResolve.mock.calls[0][0];
|
||||
expect(resolutions).toHaveLength(2);
|
||||
expect(resolutions[0]).toEqual({ key: "maxConcurrent", value: 2 });
|
||||
expect(resolutions[1]).toEqual({ key: "defaultModelId", value: "claude-sonnet" });
|
||||
});
|
||||
});
|
||||
|
||||
it("Cancel calls onClose", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SettingsSyncConflictModal {...makeProps({ onClose })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Confirm shows loading state", async () => {
|
||||
let resolvePromise: () => void;
|
||||
const onResolve = vi.fn().mockImplementation(
|
||||
() => new Promise<void>((resolve) => { resolvePromise = resolve; })
|
||||
);
|
||||
render(<SettingsSyncConflictModal {...makeProps({ onResolve })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
expect(screen.getByText("Resolving...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Resolving..." })).toBeDisabled();
|
||||
|
||||
resolvePromise!();
|
||||
});
|
||||
|
||||
it("Error during resolution shows error toast", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onResolve = vi.fn().mockRejectedValue(new Error("Sync failed"));
|
||||
render(<SettingsSyncConflictModal {...makeProps({ addToast, onResolve })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Sync failed", "error");
|
||||
});
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff rendering", () => {
|
||||
it("diff content rendered in pre tags", () => {
|
||||
render(<SettingsSyncConflictModal {...makeProps()} />);
|
||||
const diffContents = document.querySelectorAll(".settings-sync-conflict-modal__diff-content pre");
|
||||
expect(diffContents.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { SettingsSyncLog } from "../SettingsSyncLog";
|
||||
import type { SyncLogEntry } from "../SettingsSyncLog";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Upload: () => <span data-testid="upload-icon">upload</span>,
|
||||
Download: () => <span data-testid="download-icon">download</span>,
|
||||
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
|
||||
}));
|
||||
|
||||
function makeEntry(overrides: Partial<SyncLogEntry> = {}): SyncLogEntry {
|
||||
return {
|
||||
id: "sync-1",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
direction: "push",
|
||||
result: "success",
|
||||
nodeId: "node-1",
|
||||
nodeName: "Build Server",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsSyncLog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("basic rendering", () => {
|
||||
it("renders entries count in header", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1" }),
|
||||
makeEntry({ id: "2" }),
|
||||
makeEntry({ id: "3" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
expect(screen.getByText("3 entries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders single entry correctly", () => {
|
||||
const entries = [makeEntry({ id: "1" })];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
expect(screen.getByText("1 entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders entries in chronological order (newest first)", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", timestamp: "2026-04-14T10:00:00.000Z" }),
|
||||
makeEntry({ id: "2", timestamp: "2026-04-14T11:00:00.000Z" }),
|
||||
makeEntry({ id: "3", timestamp: "2026-04-14T12:00:00.000Z" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
// Expand the list
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
// Count entries - should be 3
|
||||
const entryNodes = document.querySelectorAll(".settings-sync-log__entry");
|
||||
expect(entryNodes.length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows correct direction icons", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", direction: "push" }),
|
||||
makeEntry({ id: "2", direction: "pull" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByTestId("upload-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows correct result badges", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", result: "success" }),
|
||||
makeEntry({ id: "2", result: "conflict" }),
|
||||
makeEntry({ id: "3", result: "error" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByText("Success")).toBeInTheDocument();
|
||||
expect(screen.getByText("Conflict")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
|
||||
const successBadge = document.querySelector(".settings-sync-log__badge--success");
|
||||
const conflictBadge = document.querySelector(".settings-sync-log__badge--conflict");
|
||||
const errorBadge = document.querySelector(".settings-sync-log__badge--error");
|
||||
|
||||
expect(successBadge).toBeInTheDocument();
|
||||
expect(conflictBadge).toBeInTheDocument();
|
||||
expect(errorBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows node names", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", nodeName: "Server Alpha" }),
|
||||
makeEntry({ id: "2", nodeName: "Server Beta" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={false} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
// Use queryAllByText with exact match for the entry node spans
|
||||
const nodeSpans = document.querySelectorAll(".settings-sync-log__entry-node");
|
||||
expect(nodeSpans[0].textContent).toBe("Server Alpha");
|
||||
expect(nodeSpans[1].textContent).toBe("Server Beta");
|
||||
});
|
||||
|
||||
it("shows details when present", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", details: "3 settings changed" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByText("3 settings changed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering", () => {
|
||||
it("direction filter works", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", direction: "push" }),
|
||||
makeEntry({ id: "2", direction: "pull" }),
|
||||
makeEntry({ id: "3", direction: "push" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
const directionSelect = screen.getByRole("combobox", { name: "Direction:" });
|
||||
fireEvent.change(directionSelect, { target: { value: "push" } });
|
||||
|
||||
// When filter is set, entry count in header should still show 3
|
||||
// but actual list entries should be filtered
|
||||
fireEvent.change(directionSelect, { target: { value: "pull" } });
|
||||
fireEvent.change(directionSelect, { target: { value: "all" } });
|
||||
});
|
||||
|
||||
it("node filter works when singleNode is not set", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "1", nodeName: "Build Server" }),
|
||||
makeEntry({ id: "2", nodeName: "GPU Cluster" }),
|
||||
];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={false} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Node:" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides node filter when singleNode is true", () => {
|
||||
const entries = [makeEntry({ id: "1" })];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} singleNode={true} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.queryByRole("combobox", { name: "Node:" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("states", () => {
|
||||
it("empty state when no entries", () => {
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByText("No sync history available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loading state", () => {
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={[]} loading={true} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsible toggle", () => {
|
||||
it("default state is collapsed", () => {
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
|
||||
|
||||
// Entry list should not be visible (use entry count text as proxy)
|
||||
expect(screen.getByText("1 entry")).toBeInTheDocument();
|
||||
expect(document.querySelector(".settings-sync-log__list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands on click", () => {
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
expect(document.querySelector(".settings-sync-log__list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses on second click", () => {
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={[makeEntry()]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
expect(document.querySelector(".settings-sync-log__list")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
expect(document.querySelector(".settings-sync-log__list")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestamp formatting", () => {
|
||||
it("formats timestamps for display", () => {
|
||||
const entries = [makeEntry({ timestamp: "2026-04-14T10:30:00.000Z" })];
|
||||
render(<SettingsSyncLog nodeId="node-1" entries={entries} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("settings-sync-log-header"));
|
||||
|
||||
// Check that timestamps are present
|
||||
const timestampSpans = document.querySelectorAll(".settings-sync-log__entry-timestamp");
|
||||
expect(timestampSpans.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user