import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { NodeDetailModal } from "../NodeDetailModal"; import type { NodeInfo, ProjectInfo } from "../../api"; import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync"; vi.mock("lucide-react", () => ({ Activity: () => activity, Download: () => download, Pencil: () => pencil, Save: () => save, Shield: () => shield, Upload: () => upload, X: () => x, ChevronDown: () => chevron, })); vi.mock("../../hooks/useNodeSettingsSync", () => ({ formatRelativeTime: vi.fn((ts: string | null) => { if (!ts) return "Never synced"; return "Synced 2m ago"; }), getSyncStateColor: vi.fn((state: string) => { switch (state) { case "synced": return "var(--color-success)"; case "diff": return "var(--warning)"; case "error": return "var(--color-error)"; case "pending": return "var(--warning)"; case "never-synced": return "var(--text-muted)"; default: return "var(--text-muted)"; } }), })); function makeNode(overrides: Partial = {}): NodeInfo { return { id: "node-1", name: "Test Node", type: "remote", status: "online", url: "https://test.example.com", maxConcurrent: 2, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, }; } function makeProject(overrides: Partial = {}): ProjectInfo { return { id: "proj-1", name: "Project One", path: "/workspace/project-one", status: "active", isolationMode: "in-process", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, }; } function makeSyncStatus(overrides: Partial = {}): ComputedNodeSyncStatus { return { syncState: "synced", lastSyncAt: new Date(Date.now() - 120000).toISOString(), diffCount: 0, ...overrides, }; } const defaultProps = { isOpen: true, onClose: vi.fn(), node: makeNode(), projects: [], onUpdate: vi.fn().mockResolvedValue(undefined), onHealthCheck: vi.fn().mockResolvedValue(undefined), addToast: vi.fn(), }; describe("NodeDetailModal", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("basic rendering", () => { it("renders modal when isOpen is true", () => { render(); expect(screen.getByRole("dialog")).toBeInTheDocument(); }); it("does not render modal when isOpen is false", () => { render(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("renders node name in dialog title", () => { const node = makeNode({ name: "Custom Node Name" }); render(); expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument(); }); it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => { render(); expect(screen.getByText("Overview")).toBeInTheDocument(); expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument(); expect(screen.getByText("Health")).toBeInTheDocument(); expect(screen.getByText("Settings Sync")).toBeInTheDocument(); }); it("does not render Settings Sync section for local nodes", () => { const localNode = makeNode({ type: "local" }); render(); expect(screen.getByText("Overview")).toBeInTheDocument(); expect(screen.getByText(/^Projects \(\d+\)$/)).toBeInTheDocument(); expect(screen.getByText("Health")).toBeInTheDocument(); expect(screen.queryByText("Settings Sync")).not.toBeInTheDocument(); }); }); describe("Settings Sync section", () => { it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => { render(); expect(screen.getByText("Push Settings")).toBeInTheDocument(); expect(screen.getByText("Pull Settings")).toBeInTheDocument(); expect(screen.getByText("Sync Auth")).toBeInTheDocument(); }); it("displays sync status with last sync time", () => { const syncStatus = makeSyncStatus({ syncState: "synced" }); render(); expect(screen.getByText(/Last sync:/)).toBeInTheDocument(); }); it("displays diff count when available", () => { const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 }); render(); // The diff count is displayed with the "Differences:" label const diffElement = document.querySelector(".node-detail-modal__sync-diff"); expect(diffElement).toBeInTheDocument(); expect(diffElement?.textContent).toContain("Differences:"); expect(diffElement?.textContent).toContain("3"); }); it("displays 'Never synced' when lastSyncAt is null", () => { const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null }); render(); expect(screen.getByText(/Never synced/)).toBeInTheDocument(); }); it("Push Settings button calls onPushSettings", async () => { const onPushSettings = vi.fn().mockResolvedValue(undefined); render(); await act(async () => { fireEvent.click(screen.getByText("Push Settings")); }); expect(onPushSettings).toHaveBeenCalledWith(defaultProps.node!.id); }); it("Pull Settings button calls onPullSettings", async () => { const onPullSettings = vi.fn().mockResolvedValue(undefined); render(); await act(async () => { fireEvent.click(screen.getByText("Pull Settings")); }); expect(onPullSettings).toHaveBeenCalledWith(defaultProps.node!.id); }); it("Sync Auth button calls onSyncAuth", async () => { const onSyncAuth = vi.fn().mockResolvedValue(undefined); render(); await act(async () => { fireEvent.click(screen.getByText("Sync Auth")); }); expect(onSyncAuth).toHaveBeenCalledWith(defaultProps.node!.id); }); it("shows loading state on Push Settings button during operation", async () => { const onPushSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); render(); await act(async () => { fireEvent.click(screen.getByText("Push Settings")); }); expect(screen.getByText("Pushing...")).toBeInTheDocument(); }); it("shows loading state on Pull Settings button during operation", async () => { const onPullSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); render(); await act(async () => { fireEvent.click(screen.getByText("Pull Settings")); }); expect(screen.getByText("Pulling...")).toBeInTheDocument(); }); it("shows loading state on Sync Auth button during operation", async () => { const onSyncAuth = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); render(); await act(async () => { fireEvent.click(screen.getByText("Sync Auth")); }); expect(screen.getByText("Syncing...")).toBeInTheDocument(); }); it("displays sync error when push operation fails", async () => { const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed: connection refused")); const addToast = vi.fn(); render( ); await act(async () => { fireEvent.click(screen.getByText("Push Settings")); await new Promise((resolve) => setTimeout(resolve, 10)); }); expect(screen.getByText(/Push failed: connection refused/)).toBeInTheDocument(); }); it("displays sync error when pull operation fails", async () => { const onPullSettings = vi.fn().mockRejectedValue(new Error("Pull failed: timeout")); const addToast = vi.fn(); render( ); await act(async () => { fireEvent.click(screen.getByText("Pull Settings")); await new Promise((resolve) => setTimeout(resolve, 10)); }); expect(screen.getByText(/Pull failed: timeout/)).toBeInTheDocument(); }); it("displays sync error when auth sync operation fails", async () => { const onSyncAuth = vi.fn().mockRejectedValue(new Error("Auth sync failed")); const addToast = vi.fn(); render( ); await act(async () => { fireEvent.click(screen.getByText("Sync Auth")); await new Promise((resolve) => setTimeout(resolve, 10)); }); expect(screen.getByText(/Auth sync failed/)).toBeInTheDocument(); }); it("dismisses sync error when dismiss button is clicked", async () => { const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed")); render(); await act(async () => { fireEvent.click(screen.getByText("Push Settings")); await new Promise((resolve) => setTimeout(resolve, 10)); }); expect(screen.getByText(/Push failed/)).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByLabelText("Dismiss error")); }); expect(screen.queryByText(/Push failed/)).not.toBeInTheDocument(); }); it("buttons are disabled when no handlers provided", () => { render(); expect(screen.getByText("Push Settings")).toBeDisabled(); expect(screen.getByText("Pull Settings")).toBeDisabled(); expect(screen.getByText("Sync Auth")).toBeDisabled(); }); }); });