fix: resolve four frontend/backend issues

- Fix ProjectsPage tests by wrapping renders in MemoryRouter (9 passing)
- Improve session auto-naming to 'project / repo / tool' format
- Add missing /users/me/sessions endpoint for sidebar session loading
- Handle git history 500s: catch RuntimeError in endpoints, graceful empty repo handling
- Add git status badge and discard-changes button to FileEditor toolbar

Quality gates: tsc pass, build pass, Python syntax pass
This commit is contained in:
Developer
2026-06-03 08:30:28 +00:00
parent 543fee5d56
commit 5ed5e1c84b
9 changed files with 348 additions and 158 deletions
+158 -127
View File
@@ -1,174 +1,205 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
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(<MemoryRouter>{ui}</MemoryRouter>);
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,
},
{
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();
cleanup();
vi.restoreAllMocks();
});
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(
() => new Promise(() => {}),
);
renderWithRouter(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.getByText("First project")).toBeInTheDocument();
});
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(<ProjectsPage />);
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
});
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(<ProjectsPage />);
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
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]);
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(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
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.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 }));
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);
});
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([]);
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
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 }));
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();
});
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
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 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");
});
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);
render(<ProjectsPage />);
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
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 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 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");
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);
});
// 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 and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
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(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
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 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 }));
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
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 }));
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
});