import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { AddNodeModal } from "../AddNodeModal"; describe("AddNodeModal", () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined), onDiscoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }), addToast: vi.fn(), projects: [ { id: "proj-1", name: "Project One", path: "/workspace/project-one", status: "active" as const, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, { id: "proj-2", name: "Project Two", path: "/workspace/project-two", status: "active" as const, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ], }; beforeEach(() => { vi.clearAllMocks(); }); it("renders when isOpen is true", () => { render(); expect(screen.getByLabelText("Add Node")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Build Machine")).toBeInTheDocument(); }); it("does not render when isOpen is false", () => { render(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("validates required name field", async () => { render(); // Try to submit without filling name fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Name is required")).toBeInTheDocument(); expect(defaultProps.addToast).not.toHaveBeenCalled(); }); it("validates URL required when type is remote", async () => { render(); // Switch to remote type fireEvent.click(screen.getByRole("button", { name: "Remote" })); // Fill name but not URL fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Remote Node" }, }); // Try to submit fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("URL is required for remote nodes")).toBeInTheDocument(); expect(defaultProps.addToast).not.toHaveBeenCalled(); }); it("validates max concurrent range", async () => { render(); // Fill name fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Test Node" }, }); const maxConcurrentInput = screen.getAllByRole("spinbutton")[0]; fireEvent.change(maxConcurrentInput, { target: { value: "15" }, }); // Try to submit fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Concurrency must be between 1 and 10")).toBeInTheDocument(); }); it("calls onSubmit with correct input on valid submission", async () => { render(); // Fill form fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Test Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); await waitFor(() => { expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: "Test Node", type: "local", url: undefined, apiKey: undefined, maxConcurrent: 2, projectMappings: [], })); expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success"); expect(defaultProps.onClose).toHaveBeenCalled(); }); }); it("shows error toast on submission failure", async () => { const errorOnSubmit = vi.fn().mockRejectedValue(new Error("Network error")); render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Test Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); await waitFor(() => { expect(defaultProps.addToast).toHaveBeenCalledWith("Network error", "error"); expect(defaultProps.onClose).not.toHaveBeenCalled(); }); }); it("resets form on close", () => { render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Test Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it("handles Escape key to close", () => { render(); fireEvent.keyDown(document, { key: "Escape" }); expect(defaultProps.onClose).toHaveBeenCalled(); }); it("toggles between local and remote type", async () => { render(); // Initially local is selected const localBtn = screen.getByRole("button", { name: "Local" }); const remoteBtn = screen.getByRole("button", { name: "Remote" }); expect(localBtn).toHaveAttribute("aria-pressed", "true"); expect(remoteBtn).toHaveAttribute("aria-pressed", "false"); // Remote fields container should not be rendered expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument(); // Switch to remote fireEvent.click(remoteBtn); expect(localBtn).toHaveAttribute("aria-pressed", "false"); expect(remoteBtn).toHaveAttribute("aria-pressed", "true"); // Remote fields container should be visible const remoteFieldsContainer = screen.getByTestId("remote-fields-container"); expect(remoteFieldsContainer).toBeInTheDocument(); // URL and API Key fields should be visible expect(screen.getByPlaceholderText("https://node.example.com")).toBeInTheDocument(); // Switch back to local fireEvent.click(localBtn); expect(localBtn).toHaveAttribute("aria-pressed", "true"); expect(remoteBtn).toHaveAttribute("aria-pressed", "false"); // Remote fields container should be removed again expect(screen.queryByTestId("remote-fields-container")).not.toBeInTheDocument(); }); it("submit button is disabled while submitting", async () => { let resolveSubmit: () => void; const slowSubmit = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveSubmit = resolve; }) ); render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Test Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); // Button should show "Adding..." and be disabled expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled(); // Resolve the submit resolveSubmit!(); }); it("clicking overlay closes modal", () => { render(); // Click on the overlay (not the modal itself) const overlay = screen.getByRole("dialog").parentElement!; fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it("shows hint text for max concurrent field", () => { render(); expect(screen.getByText("Max simultaneous task agents (1–10)")).toBeInTheDocument(); }); it("shows description text", () => { render(); expect( screen.getByText("Register an existing Fusion node by providing its connection details and concurrency settings.") ).toBeInTheDocument(); }); it("submits remote node with URL and API key after discovery", async () => { const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({ projects: [{ id: "remote-1", name: "Project One", path: "/srv/project-one", status: "active", isolationMode: "in-process", }], }); render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Remote Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.change(screen.getByLabelText("API Key Mode"), { target: { value: "provide" }, }); fireEvent.change(screen.getByPlaceholderText("Enter node API key"), { target: { value: "secret-key" }, }); fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" })); await waitFor(() => { expect(onDiscoverRemoteProjects).toHaveBeenCalledWith({ url: "https://node.example.com", apiKey: "secret-key", }); }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); await waitFor(() => { expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: "Remote Node", type: "remote", url: "https://node.example.com", apiKey: "secret-key", maxConcurrent: 2, projectMappings: [], })); }); }); it("blocks remote submit until discovery succeeds", async () => { render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Remote Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Discover remote projects before adding this node.")).toBeInTheDocument(); expect(defaultProps.onSubmit).not.toHaveBeenCalled(); }); it("validates selected project path is required", async () => { render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Node With Project" }, }); fireEvent.click(screen.getByRole("checkbox", { name: "Project One" })); fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), { target: { value: "" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Path is required")).toBeInTheDocument(); expect(defaultProps.onSubmit).not.toHaveBeenCalled(); }); it("validates selected project path is absolute", async () => { render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Node With Project" }, }); fireEvent.click(screen.getByRole("checkbox", { name: "Project One" })); fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), { target: { value: "relative/path" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Path must be absolute")).toBeInTheDocument(); expect(defaultProps.onSubmit).not.toHaveBeenCalled(); }); it("prefills path from exact-name discovery match", async () => { const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({ projects: [{ id: "remote-1", name: "Project One", path: "/remote/project-one", status: "active", isolationMode: "in-process", }], }); render(); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" })); await waitFor(() => { expect(onDiscoverRemoteProjects).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("checkbox", { name: "Project One" })); expect(screen.getByDisplayValue("/remote/project-one")).toBeInTheDocument(); }); it("leaves ambiguous name matches unresolved", async () => { const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({ projects: [ { id: "remote-1", name: "Project One", path: "/remote/project-one-a", status: "active", isolationMode: "in-process", }, { id: "remote-2", name: "Project One", path: "/remote/project-one-b", status: "paused", isolationMode: "child-process", }, ], }); render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Remote Node" }, }); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" })); await waitFor(() => { expect(onDiscoverRemoteProjects).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("checkbox", { name: "Project One" })); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); expect(await screen.findByText("Path is required")).toBeInTheDocument(); }); it("shows discovery error inline", async () => { const onDiscoverRemoteProjects = vi.fn().mockRejectedValue(new Error("upstream unavailable")); render(); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" })); expect(await screen.findByText("upstream unavailable")).toBeInTheDocument(); }); it("shows explicit zero-project discovery state", async () => { render(); fireEvent.click(screen.getByRole("button", { name: "Remote" })); fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), { target: { value: "https://node.example.com" }, }); fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" })); expect(await screen.findByText("No projects discovered on remote node.")).toBeInTheDocument(); }); it("submits selected project mappings", async () => { render(); fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "Node With Project" }, }); fireEvent.click(screen.getByRole("checkbox", { name: "Project One" })); fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), { target: { value: "/mnt/node/project-one" }, }); fireEvent.click(screen.getByRole("button", { name: "Add Node" })); await waitFor(() => { expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: "Node With Project", projectMappings: [{ projectId: "proj-1", path: "/mnt/node/project-one" }], })); }); }); it("removes path input when project is deselected", () => { render(); const checkbox = screen.getByRole("checkbox", { name: "Project One" }); fireEvent.click(checkbox); expect(screen.getByDisplayValue("/workspace/project-one")).toBeInTheDocument(); fireEvent.click(checkbox); expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument(); }); });