mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
|
|
// @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 }) => (
|
|||
|
|
<a href={to} className={className}>{children}</a>
|
|||
|
|
),
|
|||
|
|
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: () => <div data-testid="runtime-controls" />,
|
|||
|
|
}));
|
|||
|
|
vi.mock("@/plugins/slots", () => ({
|
|||
|
|
PluginSlotMount: (props: unknown) => {
|
|||
|
|
mockPluginSlotMount(props);
|
|||
|
|
return <div data-testid="plugin-slot-mount" />;
|
|||
|
|
},
|
|||
|
|
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;
|
|||
|
|
}) => (
|
|||
|
|
<div data-testid="page-tab-bar">
|
|||
|
|
{items.map((item) => (
|
|||
|
|
<button
|
|||
|
|
key={item.value}
|
|||
|
|
data-tab-value={item.value}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => onValueChange?.(item.value)}
|
|||
|
|
>
|
|||
|
|
{item.label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): 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> = {}): 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<string, unknown> = {}) {
|
|||
|
|
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(
|
|||
|
|
<QueryClientProvider client={queryClient}>
|
|||
|
|
<ProjectWorkspaceDetail />
|
|||
|
|
</QueryClientProvider>,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
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");
|
|||
|
|
});
|
|||
|
|
});
|