// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Project, ProjectWorkspace } 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 { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const mockProjectsApi = vi.hoisted(() => ({ get: vi.fn(), updateWorkspace: vi.fn(), controlWorkspaceCommands: vi.fn(), })); const mockNavigate = vi.hoisted(() => vi.fn()); const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn()); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockPluginSlotMount = vi.hoisted(() => vi.fn()); const mockRouteSearch = vi.hoisted(() => ({ value: "" })); const mockPluginSlotState = vi.hoisted(() => ({ slots: [] as unknown[], isLoading: false, errorMessage: null as string | null, })); vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => ( {children} ), useLocation: () => ({ pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1", search: mockRouteSearch.value, hash: "", state: null, }), useNavigate: () => mockNavigate, useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }), })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: mockSetSelectedCompanyId, }), })); vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) })); vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null })); vi.mock("../components/WorkspaceRuntimeControls", () => ({ buildWorkspaceRuntimeControlSections: () => [], WorkspaceRuntimeControls: () =>
, })); vi.mock("@/plugins/slots", () => ({ PluginSlotMount: (props: unknown) => { mockPluginSlotMount(props); return
; }, usePluginSlots: (filters: unknown) => { mockUsePluginSlots(filters); const entityType = (filters as { entityType?: string }).entityType; return { slots: entityType === "project_workspace" ? mockPluginSlotState.slots : [], isLoading: mockPluginSlotState.isLoading, errorMessage: mockPluginSlotState.errorMessage, }; }, })); vi.mock("../components/PageTabBar", () => ({ PageTabBar: ({ items, onValueChange, }: { items: Array<{ value: string; label: string }>; onValueChange?: (value: string) => void; }) => (
{items.map((item) => ( ))}
), })); function projectWorkspace(overrides: Partial = {}): ProjectWorkspace { const now = new Date("2026-05-01T00:00:00Z"); return { id: "workspace-1", companyId: "company-1", projectId: "project-1", name: "Primary checkout", sourceType: "local_path", cwd: "/tmp/paperclip", repoUrl: "https://github.com/paperclipai/paperclip", repoRef: "master", defaultRef: "origin/main", visibility: "default", setupCommand: null, cleanupCommand: null, remoteProvider: null, remoteWorkspaceRef: null, sharedWorkspaceKey: null, metadata: null, runtimeConfig: null, runtimeServices: [], isPrimary: true, createdAt: now, updatedAt: now, ...overrides, }; } function project(overrides: Partial = {}): Project { const now = new Date("2026-05-01T00:00:00Z"); const workspace = projectWorkspace(); return { id: "project-1", companyId: "company-1", urlKey: "paperclip-app", goalId: null, goalIds: [], goals: [], name: "Paperclip App", description: null, status: "in_progress", leadAgentId: null, targetDate: null, color: "#14b8a6", env: null, pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, codebase: { workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, defaultRef: workspace.defaultRef, repoName: "paperclip", localFolder: workspace.cwd, managedFolder: workspace.cwd ?? "/tmp/paperclip", effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip", origin: "local_folder", }, workspaces: [workspace], primaryWorkspace: workspace, managedByPlugin: null, archivedAt: null, createdAt: now, updatedAt: now, ...overrides, }; } async function flush() { await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); } function pluginSlot(overrides: Record = {}) { return { id: "quality-tab", type: "detailTab", displayName: "Quality", exportName: "ProjectWorkspaceQualityTab", entityTypes: ["project_workspace"], pluginId: "plugin-1", pluginKey: "paperclip.quality", pluginDisplayName: "Quality Plugin", pluginVersion: "0.1.0", ...overrides, }; } describe("ProjectWorkspaceDetail plugin tabs", () => { let root: Root | null = null; let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); mockProjectsApi.get.mockResolvedValue(project()); mockPluginSlotState.slots = []; mockPluginSlotState.isLoading = false; mockPluginSlotState.errorMessage = null; }); afterEach(() => { act(() => root?.unmount()); root = null; container.remove(); vi.clearAllMocks(); mockRouteSearch.value = ""; }); async function render() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root = createRoot(container); root.render( , ); }); await act(async () => { await flush(); }); } it("scopes plugin detail-tab discovery to project_workspace and the project's company", async () => { await render(); const enabledDetailTabFilters = mockUsePluginSlots.mock.calls .map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean }) .filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false); expect(enabledDetailTabFilters.length).toBeGreaterThan(0); for (const filters of enabledDetailTabFilters) { expect(filters.entityType).toBe("project_workspace"); expect(filters.companyId).toBe("company-1"); } }); it("renders an arbitrary project_workspace plugin detail tab from the generic URL value", async () => { mockPluginSlotState.slots = [pluginSlot()]; mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab&diffView=head&baseRef=origin%2Fmaster"; await render(); expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration"); expect(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]')?.textContent).toBe("Quality"); expect(container.querySelector('[data-tab-value="changes"]')).toBeNull(); expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull(); expect(mockPluginSlotMount).toHaveBeenCalledWith( expect.objectContaining({ slot: expect.objectContaining({ pluginKey: "paperclip.quality", id: "quality-tab" }), context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }), }), ); }); it("keeps the project workspace heading visible on plugin tabs", async () => { mockPluginSlotState.slots = [pluginSlot({ displayName: "Changes" })]; mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab"; await render(); expect(container.querySelector("h1")?.textContent).toBe("Primary checkout"); expect(container.textContent).toContain("Project workspace"); expect(container.textContent).toContain("This is the project’s primary codebase workspace."); expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull(); expect(container.textContent).not.toContain("Configure the concrete workspace"); expect(container.textContent).not.toContain("Workspace name"); }); it("orders project workspace plugin tabs against built-in tabs by slot order", async () => { mockPluginSlotState.slots = [ pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }), pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }), pluginSlot({ id: "default-tab", displayName: "Default" }), ]; await render(); const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]); }); it("navigates plugin tabs with only the generic plugin tab parameter", async () => { mockPluginSlotState.slots = [pluginSlot()]; await render(); await act(async () => { (container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click(); }); expect(mockNavigate).toHaveBeenCalledWith( "/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab", ); expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView")); expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef")); }); it("does not treat the old changes tab query as a core plugin tab", async () => { mockPluginSlotState.slots = [pluginSlot()]; mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain"; await render(); expect(container.querySelector('[data-tab-value="changes"]')).toBeNull(); expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); expect(container.textContent).toContain("Project workspace"); }); it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => { mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot"; await render(); expect(container.textContent).toContain("Workspace plugin tab is not available."); expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe( "Back to configuration", ); expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); expect(container.textContent).not.toContain("Configure the concrete workspace"); expect(container.textContent).not.toContain("Workspace name"); }); it("shows loading and error states for plugin tab manifests", async () => { mockPluginSlotState.isLoading = true; mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab"; await render(); expect(container.textContent).toContain("Loading workspace plugin..."); act(() => root?.unmount()); root = null; container.innerHTML = ""; vi.clearAllMocks(); mockProjectsApi.get.mockResolvedValue(project()); mockPluginSlotState.isLoading = false; mockPluginSlotState.errorMessage = "Plugin manifest failed"; await render(); expect(container.textContent).toContain("Plugin manifest failed"); }); });