import { cleanup, fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ProjectsPage } from "./ProjectsPage"; import * as projectsApi from "../api/projects"; const renderWithRouter = (ui: React.ReactElement) => render({ui}); const mockProjects = [ { id: "proj-1", name: "Alpha Project", description: "First project", owner_id: "user-1", default_ssh_key_id: null, }, { id: "proj-2", name: "Beta Project", description: null, owner_id: "user-1", default_ssh_key_id: null, }, ]; afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("ProjectsPage", () => { it("renders loading state initially", () => { vi.spyOn(projectsApi, "listProjects").mockImplementation( () => new Promise(() => {}), ); renderWithRouter(); expect(screen.getByText(/loading projects/i)).toBeInTheDocument(); }); it("renders project list after loading", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); renderWithRouter(); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); expect(screen.getByText("Beta Project")).toBeInTheDocument(); expect(screen.getByText("First project")).toBeInTheDocument(); }); it("renders empty state when no projects", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); renderWithRouter(); await waitFor(() => { expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); }); }); it("renders error state with retry button", async () => { vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail")); renderWithRouter(); await waitFor(() => { expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument(); }); expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); it("opens create dialog and submits new project", async () => { const listMock = vi .spyOn(projectsApi, "listProjects") .mockResolvedValue([]); const createMock = vi .spyOn(projectsApi, "createProject") .mockResolvedValue(mockProjects[0]); renderWithRouter(); await waitFor(() => { expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /new project/i })); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/project name/i), { target: { value: "Gamma Project" }, }); fireEvent.change(screen.getByPlaceholderText(/optional description/i), { target: { value: "A new project" }, }); fireEvent.click(screen.getByRole("button", { name: /create/i })); await waitFor(() => { expect(createMock).toHaveBeenCalledWith({ name: "Gamma Project", description: "A new project", }); }); expect(listMock).toHaveBeenCalledTimes(2); }); it("shows validation error when name is empty", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); renderWithRouter(); await waitFor(() => { expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /new project/i })); fireEvent.click(screen.getByRole("button", { name: /create/i })); expect(screen.getByText(/project name is required/i)).toBeInTheDocument(); }); it("renders settings link for each project", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); renderWithRouter(); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); const alphaCard = screen .getByText("Alpha Project") .closest(".project-card") as HTMLElement | null; if (!alphaCard) throw new Error("Card not found"); const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i, }); expect(settingsLink).toBeInTheDocument(); expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings"); }); it("renders open workspace link as rightmost action", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); renderWithRouter(); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); const alphaCard = screen .getByText("Alpha Project") .closest(".project-card") as HTMLElement | null; if (!alphaCard) throw new Error("Card not found"); const actions = alphaCard.querySelector(".project-actions"); if (!actions) throw new Error("Actions container not found"); const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i, }); expect(workspaceLink).toBeInTheDocument(); expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1"); // Verify it's the last action in the container const allActions = actions.querySelectorAll("a, button"); const lastAction = allActions[allActions.length - 1]; expect(lastAction).toBe(workspaceLink); }); it("shows delete confirmation dialog and deletes project", async () => { const listMock = vi .spyOn(projectsApi, "listProjects") .mockResolvedValue(mockProjects); const deleteMock = vi .spyOn(projectsApi, "deleteProject") .mockResolvedValue(undefined); renderWithRouter(); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); const alphaCard = screen .getByText("Alpha Project") .closest(".project-card") as HTMLElement | null; if (!alphaCard) throw new Error("Card not found"); fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i })); const dialog = screen.getByRole("dialog"); expect(dialog).toBeInTheDocument(); expect(within(dialog).getByText(/delete workspace/i)).toBeInTheDocument(); expect(within(dialog).getByText(/are you sure/i)).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: /delete/i })); await waitFor(() => { expect(deleteMock).toHaveBeenCalledWith("proj-1"); }); expect(listMock).toHaveBeenCalledTimes(2); }); });