6698c20f25
- Retain xterm normal-buffer history on mobile while preserving desktop zero-scrollback behavior\n- Repair ProjectsPage tests for session context and current project list markup\n- Add focused terminal scrollback coverage\n\nOpenSpec: fix-mobile-terminal-scrolling\nQuality gates: npm run typecheck, npm run lint, npm test (89 passed), npm run build
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
import "@testing-library/jest-dom/vitest";
|
|
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";
|
|
import { SessionsProvider } from "../state/sessions";
|
|
import type { ProjectWithRepos } from "../types";
|
|
|
|
vi.mock("../api/sessions", () => ({
|
|
getUserSessions: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
const mockProjects: ProjectWithRepos[] = [
|
|
{
|
|
id: "proj-1",
|
|
name: "Alpha Project",
|
|
description: "First project",
|
|
owner_id: "user-1",
|
|
default_ssh_key_id: null,
|
|
repositories: [],
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
},
|
|
{
|
|
id: "proj-2",
|
|
name: "Beta Project",
|
|
description: null,
|
|
owner_id: "user-1",
|
|
default_ssh_key_id: null,
|
|
repositories: [],
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
},
|
|
];
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("ProjectsPage", () => {
|
|
it("renders loading state initially", () => {
|
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders project list after loading", async () => {
|
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
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([]);
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
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"));
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
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]);
|
|
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
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([]);
|
|
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
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("opens edit dialog and saves changes", async () => {
|
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
|
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
|
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
|
});
|
|
|
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
|
if (!alphaCard) throw new Error("Card not found");
|
|
|
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
|
|
const nameInput = screen.getByDisplayValue("Alpha Project");
|
|
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(updateMock).toHaveBeenCalledWith("proj-1", {
|
|
name: "Alpha Updated",
|
|
description: "First project",
|
|
});
|
|
});
|
|
expect(listMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("shows delete confirmation and deletes project", async () => {
|
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
|
});
|
|
|
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
|
if (!alphaCard) throw new Error("Card not found");
|
|
|
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
|
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
|
|
|
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(deleteMock).toHaveBeenCalledWith("proj-1");
|
|
});
|
|
expect(listMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|