import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SetupProjectForm } from "../SetupProjectForm";
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
Check: ({ size, ...props }: any) => ✓,
Loader2: ({ size, ...props }: any) => ⟳,
Folder: ({ size, ...props }: any) => 📁,
FolderOpen: ({ size, ...props }: any) => 📂,
ChevronRight: ({ size, ...props }: any) => →,
ChevronUp: ({ size, ...props }: any) => ↑,
Eye: ({ size, ...props }: any) => 👁,
EyeOff: ({ size, ...props }: any) => 🙈,
AlertCircle: ({ size, ...props }: any) => ⚠,
};
});
// Mock the API
vi.mock("../../api", () => ({
browseDirectory: vi.fn(),
}));
// Mock projectDetection utilities
vi.mock("../utils/projectDetection", () => ({
validateProjectPath: vi.fn((path: string) => ({
valid: path.length > 0,
error: path.length === 0 ? "Path is required" : undefined,
})),
validateProjectName: vi.fn((name: string) => ({
valid: name.length > 0 && /^[a-zA-Z0-9_-]+$/.test(name),
error:
name.length === 0
? "Name is required"
: !/^[a-zA-Z0-9_-]+$/.test(name)
? "Use letters, numbers, hyphens, and underscores only"
: undefined,
})),
suggestProjectName: vi.fn((path: string) => {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || "";
}),
}));
// Mock DirectoryPicker
vi.mock("../DirectoryPicker", () => ({
DirectoryPicker: ({
value,
onChange,
nodeId,
localNodeId,
placeholder,
}: {
value: string;
onChange: (path: string) => void;
nodeId?: string;
localNodeId?: string;
placeholder?: string;
}) => (
onChange(e.target.value)}
placeholder={placeholder}
/>
{nodeId !== undefined && (
{nodeId}
)}
{localNodeId !== undefined && (
{localNodeId}
)}
),
}));
import { browseDirectory } from "../../api";
const mockBrowseDirectory = vi.mocked(browseDirectory);
const localNode: import("../../api").NodeInfo = {
id: "local-1",
name: "Local Node",
type: "local",
status: "online" as const,
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const remoteNode: import("../../api").NodeInfo = {
id: "remote-1",
name: "Remote Node",
type: "remote",
url: "http://localhost:3001",
status: "online" as const,
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe("SetupProjectForm", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBrowseDirectory.mockResolvedValue({
currentPath: "/home/user",
parentPath: "/home",
entries: [],
});
});
it("renders without crashing", () => {
render();
expect(screen.getByText("Directory Path")).toBeDefined();
});
it("has disabled submit button initially", () => {
render();
const submitBtn = screen.getByText("Create Project").closest("button") as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
describe("node selector", () => {
it("renders node selector when nodes are provided", () => {
render(
);
expect(screen.getByText("Runtime Node")).toBeDefined();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Check options directly by querying the select's children
const options = Array.from(select.querySelectorAll("option"));
const optionValues = options.map((opt) => opt.value);
expect(optionValues).toContain("local-1");
expect(optionValues).toContain("remote-1");
});
it("does not render extra node options when only local node is in list", () => {
render();
// When only the local node is provided, it appears as the default option
// The extra options from nodes.map should include localNode's entry
const select = screen.getByRole("combobox") as HTMLSelectElement;
const options = Array.from(select.querySelectorAll("option"));
// Should have at least the local node option
expect(options.some((opt) => opt.value === "local-1")).toBe(true);
});
it("uses selectedNodeId as default value", () => {
render(
);
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("remote-1");
});
it("passes nodeId and localNodeId to DirectoryPicker", () => {
render(
);
// Select the remote node
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "remote-1" } });
const nodeIdSpan = screen.getByTestId("node-id");
const localNodeIdSpan = screen.getByTestId("local-node-id");
expect(nodeIdSpan.textContent).toBe("remote-1");
expect(localNodeIdSpan.textContent).toBe("local-1");
});
it("passes undefined nodeId when local node is selected", () => {
render(
);
// Local node is selected by default (empty value)
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// nodeId should be undefined when local node is selected
const nodeIdSpan = screen.queryByTestId("node-id");
expect(nodeIdSpan).toBeNull();
});
});
describe("form submission", () => {
it("submits with nodeId when remote node is selected", async () => {
const onSubmit = vi.fn();
render(
);
// Select remote node
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "remote-1" } });
// Fill path
const pathInput = screen.getByTestId("directory-picker-input");
fireEvent.change(pathInput, { target: { value: "/home/user/project" } });
// Fill name
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "my-project" } });
// Submit
fireEvent.click(screen.getByText("Create Project"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "my-project",
path: "/home/user/project",
isolationMode: "in-process",
nodeId: "remote-1",
})
);
});
});
it("submits with undefined nodeId when local node is selected", async () => {
const onSubmit = vi.fn();
render(
);
// Local node is selected by default (empty value)
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Fill path
const pathInput = screen.getByTestId("directory-picker-input");
fireEvent.change(pathInput, { target: { value: "/home/user/project" } });
// Fill name
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "my-project" } });
// Submit
fireEvent.click(screen.getByText("Create Project"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "my-project",
path: "/home/user/project",
isolationMode: "in-process",
nodeId: undefined,
})
);
});
});
it("submits without nodeId field when no nodes provided", async () => {
const onSubmit = vi.fn();
render(
);
// Fill path
const pathInput = screen.getByTestId("directory-picker-input");
fireEvent.change(pathInput, { target: { value: "/home/user/project" } });
// Fill name
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "my-project" } });
// Submit
fireEvent.click(screen.getByText("Create Project"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "my-project",
path: "/home/user/project",
isolationMode: "in-process",
nodeId: undefined,
})
);
});
});
});
describe("validation", () => {
it("enables submit when both path and name are valid", () => {
render();
const pathInput = screen.getByTestId("directory-picker-input");
fireEvent.change(pathInput, { target: { value: "/valid/path" } });
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "valid-name" } });
const submitBtn = screen.getByText("Create Project").closest("button") as HTMLButtonElement;
expect(submitBtn.disabled).toBe(false);
});
});
});