// @vitest-environment jsdom import type { ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "../context/ToastContext"; import { Projects } from "./Projects"; const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn(), })); const mockResourceMembershipsApi = vi.hoisted(() => ({ listMine: vi.fn(), updateProject: vi.fn(), })); const mockOpenNewProject = vi.hoisted(() => vi.fn()); const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { 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 }), })); vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi, })); vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResourceMembershipsApi, })); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.PointerEvent) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).PointerEvent = MouseEvent; } async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { result = callback(); }); await result; } function makeProject(overrides: Partial): Project { return { id: "project-a", companyId: "company-1", urlKey: "alpha", goalId: null, goalIds: [], goals: [], name: "Alpha", description: null, status: "in_progress", leadAgentId: null, targetDate: null, color: "#ef4444", env: null, pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null, localFolder: null, managedFolder: "/tmp/project-a", effectiveLocalFolder: "/tmp/project-a", origin: "local_folder", }, workspaces: [], primaryWorkspace: null, managedByPlugin: null, archivedAt: null, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), ...overrides, }; } async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } describe("Projects", () => { let container: HTMLDivElement; let root: ReturnType | null; let queryClient: QueryClient; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); mockProjectsApi.list.mockResolvedValue([ makeProject({ id: "project-c", urlKey: "charlie", name: "Charlie", updatedAt: new Date("2026-01-10T00:00:00Z"), }), makeProject({ id: "project-b", urlKey: "bravo", name: "Bravo", updatedAt: new Date("2026-01-05T00:00:00Z"), }), makeProject({ id: "project-a", urlKey: "alpha", name: "Alpha", description: "First project", updatedAt: new Date("2026-01-01T00:00:00Z"), }), ]); mockResourceMembershipsApi.listMine.mockResolvedValue({ projectMemberships: { "project-b": "left" }, agentMemberships: {}, updatedAt: null, }); mockResourceMembershipsApi.updateProject.mockResolvedValue({ resourceType: "project", resourceId: "project-b", state: "joined", updatedAt: new Date("2026-01-05T00:00:00Z"), }); }); afterEach(async () => { const currentRoot = root; if (currentRoot) { await act(async () => { currentRoot.unmount(); }); } queryClient.clear(); container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); }); async function renderProjects() { const currentRoot = createRoot(container); root = currentRoot; await act(async () => { currentRoot.render( , ); }); await flushReact(); await flushReact(); } async function openSortMenu() { const trigger = container.querySelector('button[title="Sort"]'); expect(trigger).not.toBeNull(); await act(async () => { trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } async function chooseSortField(label: string) { const item = Array.from(document.body.querySelectorAll("button")) .find((element) => element.textContent?.includes(label)); expect(item).toBeTruthy(); await act(async () => { item?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } it("groups joined projects above left projects and defaults sorting by name", async () => { await renderProjects(); const content = container.textContent ?? ""; expect(container.querySelector('button[title="Sort"]')?.textContent).toContain("Sort: Name"); expect(content.indexOf("My Projects")).toBeLessThan(content.indexOf("Alpha")); expect(content.indexOf("Alpha")).toBeLessThan(content.indexOf("Charlie")); expect(content.indexOf("Charlie")).toBeLessThan(content.indexOf("Other Projects")); expect(content.indexOf("Other Projects")).toBeLessThan(content.indexOf("Bravo")); expect(content).toContain("in progress"); }); it("sorts grouped projects by the selected field", async () => { await renderProjects(); await openSortMenu(); await chooseSortField("Updated"); const content = container.textContent ?? ""; expect(content.indexOf("My Projects")).toBeLessThan(content.indexOf("Charlie")); expect(content.indexOf("Charlie")).toBeLessThan(content.indexOf("Alpha")); expect(content.indexOf("Alpha")).toBeLessThan(content.indexOf("Other Projects")); }); it("reserves description line height for projects without descriptions", async () => { await renderProjects(); 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"); }); });