// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Projects } from "./Projects";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewProject: mockOpenNewProject }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
function project(overrides: Partial): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function renderProjects(container: HTMLElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let root: Root | null = null;
await act(async () => {
root = createRoot(container);
root.render(
,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
return root;
}
function projectLinkNames(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll("a[href^='/projects/']")).map((link) => {
const title = link.querySelector("span.truncate");
return title?.textContent ?? "";
});
}
describe("Projects", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.list.mockResolvedValue([
project({
id: "project-bravo",
urlKey: "bravo",
name: "Bravo",
description: null,
updatedAt: new Date("2026-05-02T00:00:00Z"),
}),
project({
id: "project-alpha",
urlKey: "alpha",
name: "Alpha",
description: "First project",
updatedAt: new Date("2026-05-01T00:00:00Z"),
}),
project({
id: "project-charlie",
urlKey: "charlie",
name: "Charlie",
description: null,
updatedAt: new Date("2026-05-03T00:00:00Z"),
}),
]);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("sorts projects by name by default and can switch sort mode", async () => {
root = await renderProjects(container);
expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
const select = container.querySelector("select");
expect(select).not.toBeNull();
await act(async () => {
select!.value = "updated";
select!.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
});
it("reserves description line height for projects without descriptions", async () => {
root = await renderProjects(container);
const bravoLink = Array.from(container.querySelectorAll("a")).find((link) =>
link.textContent?.includes("Bravo"),
);
const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']");
expect(hiddenDescriptionLine).not.toBeNull();
expect(hiddenDescriptionLine?.className).toContain("min-h-4");
});
});