paperclip/ui/src/pages/TeamCatalog.test.tsx

485 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
CatalogTeam,
CatalogTeamImportPreviewResult,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
TeamCatalog,
listTeamInstallAdapterTypes,
parseTeamRoute,
resolveTeamInstallAdapterType,
teamRoute,
} from "./TeamCatalog";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockTeamCatalogApi = vi.hoisted(() => ({
catalogList: vi.fn(),
catalogDetail: vi.fn(),
catalogFile: vi.fn(),
preview: vi.fn(),
install: vi.fn(),
installed: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockAdapterAvailability = vi.hoisted(() => ({
disabled: new Set<string>(["paperclip_runner"]),
loaded: true,
}));
vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => mockAdapterAvailability.disabled,
useAdapterRegistryLoaded: () => mockAdapterAvailability.loaded,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
// Drive the route deterministically: the team is preselected so the detail pane
// renders without depending on the auto-select navigation effect.
let currentRoute = "team-no-deps";
const mockSearchParams = new URLSearchParams();
vi.mock("@/lib/router", () => ({
useParams: () => ({ "*": currentRoute }),
useNavigate: () => mockNavigate,
useSearchParams: () => [mockSearchParams, vi.fn()],
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// cmdk (Command) and Radix rely on browser APIs jsdom does not implement.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub;
// jsdom has no matchMedia. Default to desktop (min-width queries match, max-width don't).
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: /min-width/.test(query),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
describe("TeamCatalog routes", () => {
it("round-trips file paths containing literal tildes", () => {
const route = teamRoute("paperclipai/bundled/test/team", "agents/a~b/AGENTS.md");
expect(route).toBe("/teams-catalog/paperclipai%2Fbundled%2Ftest%2Fteam/files/agents%7Ea~b%7EAGENTS.md");
expect(parseTeamRoute(route.replace("/teams-catalog/", ""))).toEqual({
catalogRef: "paperclipai/bundled/test/team",
filePath: "agents/a~b/AGENTS.md",
});
});
it("fails closed for Paperclip Runner until adapter availability is loaded and enabled", () => {
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("paperclip_runner");
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("process");
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("http");
expect(listTeamInstallAdapterTypes(new Set(["paperclip_runner"]), true))
.not.toContain("paperclip_runner");
expect(listTeamInstallAdapterTypes(new Set(), true)).toContain("paperclip_runner");
expect(resolveTeamInstallAdapterType("paperclip_runner", ["claude_local", "codex_local"]))
.toBe("claude_local");
expect(resolveTeamInstallAdapterType("paperclip_runner", ["codex_local"]))
.toBe("codex_local");
expect(resolveTeamInstallAdapterType("paperclip_runner", []))
.toBeNull();
});
});
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function makeTeam(overrides: Partial<CatalogTeam> = {}): CatalogTeam {
return {
id: "team-no-deps",
key: "paperclipai/bundled/company-defaults/team-no-deps",
kind: "bundled",
category: "company-defaults",
slug: "team-no-deps",
name: "Core Exec Team",
description: "A starter executive team.",
path: "catalog/bundled/company-defaults/team-no-deps",
entrypoint: "TEAM.md",
schema: "agentcompanies/v1",
defaultInstall: true,
recommendedForCompanyTypes: [],
tags: ["exec"],
counts: {
agents: 2,
projects: 1,
tasks: 0,
routines: 1,
localSkills: 0,
catalogSkills: 0,
externalSkillSources: 0,
},
rootAgentSlugs: [],
agentSlugs: ["ceo", "cto"],
projectSlugs: ["launch"],
requiredSkills: [],
envInputs: [],
sourceRefs: [],
files: [{ path: "TEAM.md", kind: "team", sizeBytes: 100, sha256: "abc" }],
trustLevel: "markdown_only",
compatibility: "compatible",
contentHash: "sha256:deadbeefdeadbeefdeadbeef",
...overrides,
};
}
function makePreview(): CatalogTeamImportPreviewResult {
return {
team: makeTeam(),
portabilityPreview: {
include: { company: false, agents: true, projects: true, issues: false, skills: true },
targetCompanyId: "company-1",
targetCompanyName: "Paperclip",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto"],
plan: {
companyAction: "none",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null },
],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-06-03T00:00:00.000Z",
source: null,
includes: { company: false, agents: true, projects: true, issues: false, skills: true },
company: null,
sidebar: null,
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
},
skillPreparations: [],
warnings: [],
errors: [],
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((b) =>
(b.textContent ?? "").includes(label),
) as HTMLButtonElement | undefined;
}
describe("TeamCatalog install preview path", () => {
let container: HTMLDivElement;
beforeEach(() => {
mockAdapterAvailability.disabled = new Set(["paperclip_runner"]);
mockAdapterAvailability.loaded = true;
container = document.createElement("div");
document.body.appendChild(container);
currentRoute = "team-no-deps";
mockAgentsApi.list.mockResolvedValue([]);
mockTeamCatalogApi.installed.mockResolvedValue([]);
mockTeamCatalogApi.catalogList.mockResolvedValue([makeTeam()]);
mockTeamCatalogApi.preview.mockResolvedValue(makePreview());
mockTeamCatalogApi.install.mockResolvedValue({
team: makeTeam(),
portabilityImport: {
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
agents: [],
projects: [],
envInputs: [],
warnings: [],
},
skillPreparations: [],
warnings: [],
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderPage() {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<TeamCatalog />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
return root;
}
it("renders the detail pane for the selected team", async () => {
await renderPage();
expect(document.body.textContent).toContain("Core Exec Team");
expect(document.body.textContent).toContain("A starter executive team.");
// summary grid counts
expect(document.body.textContent).toContain("Agents");
expect(document.body.textContent).toContain("Projects");
});
it("opens the installer, fetches the preview, and submits the install", async () => {
await renderPage();
// Open the installer from the detail CTA.
const installCta = findButton("Install team");
expect(installCta).toBeTruthy();
await act(async () => {
installCta!.click();
});
await flushReact();
// The wizard is single-step (no deps) → lands on the preview step and
// auto-loads the categorized preview.
expect(mockTeamCatalogApi.preview).toHaveBeenCalledTimes(1);
expect(mockTeamCatalogApi.preview).toHaveBeenCalledWith(
"company-1",
"team-no-deps",
expect.objectContaining({ collisionStrategy: "rename" }),
);
expect(document.body.textContent).toContain("Summary");
// categorized plan rows
expect(document.body.textContent?.toLowerCase()).toContain("ceo");
expect(document.body.textContent?.toLowerCase()).toContain("launch");
// Submit install from the footer.
const submit = Array.from(document.querySelectorAll("button")).filter((b) =>
(b.textContent ?? "").includes("Install team"),
);
// Last "Install team" is the wizard footer submit.
await act(async () => {
submit[submit.length - 1].click();
});
await flushReact();
expect(mockTeamCatalogApi.install).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Team installed");
});
it("requires and submits Step 4 secret values", async () => {
const preview = makePreview();
preview.portabilityPreview.envInputs = [
{
key: "OPENAI_API_KEY",
description: "OpenAI API key",
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
];
mockTeamCatalogApi.preview.mockResolvedValue(preview);
await renderPage();
const installCta = findButton("Install team");
await act(async () => {
installCta!.click();
});
await flushReact();
const footerInstall = Array.from(document.querySelectorAll("button")).filter((b) =>
(b.textContent ?? "").includes("Install team"),
).at(-1) as HTMLButtonElement;
expect(footerInstall.disabled).toBe(true);
expect(document.body.textContent).toContain("Required secrets missing: 1");
const secretInput = document.querySelector('input[aria-label="OPENAI_API_KEY value"]') as HTMLInputElement;
expect(secretInput).toBeTruthy();
await act(async () => {
setInputValue(secretInput, "sk-imported");
});
await flushReact();
expect(footerInstall.disabled).toBe(false);
await act(async () => {
footerInstall.click();
});
await flushReact();
expect(mockTeamCatalogApi.install).toHaveBeenCalledWith(
"company-1",
"team-no-deps",
expect.objectContaining({
secretValues: {
"agent:ceo:OPENAI_API_KEY": "sk-imported",
},
}),
);
});
it("requires a target manager before continuing when the team has root agents", async () => {
currentRoute = "team-with-root";
mockTeamCatalogApi.catalogList.mockResolvedValue([
makeTeam({ id: "team-with-root", slug: "team-with-root", rootAgentSlugs: ["ceo"], agentSlugs: ["ceo", "cto"] }),
]);
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
companyId: "company-1",
name: "Founder",
urlKey: "founder",
role: "ceo",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {},
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
await renderPage();
const installCta = findButton("Install team");
await act(async () => {
installCta!.click();
});
await flushReact();
// First step is target manager; Continue is disabled until a manager is chosen.
expect(document.body.textContent).toContain("root agents need a manager");
const continueBtn = findButton("Continue");
expect(continueBtn).toBeTruthy();
expect(continueBtn!.disabled).toBe(true);
// Preview has not been requested yet (still on step 1).
expect(mockTeamCatalogApi.preview).not.toHaveBeenCalled();
});
it("surfaces the INSTALLED group, out-of-date badge, and Re-install CTA from the server signal", async () => {
mockTeamCatalogApi.installed.mockResolvedValue([
{
catalogId: "team-no-deps",
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
present: true,
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
installedOriginHashes: ["sha256:stale"],
agentCount: 2,
outOfDate: true,
},
]);
await renderPage();
// List group header reflects the installed team, not Bundled.
expect(document.body.textContent).toContain("Installed · 1");
expect(document.body.textContent).not.toContain("Bundled · 1");
// Detail header chip + Re-install CTA replace the plain Install button.
expect(document.body.textContent).toContain("Update available");
expect(findButton("Re-install latest")).toBeTruthy();
expect(findButton("Install team")).toBeFalsy();
// The out-of-date badge in the list row exposes an accessible label.
expect(document.querySelector('[aria-label="Update available"]')).toBeTruthy();
});
it("renders an Installed badge (no update chip) when the installed team is current", async () => {
mockTeamCatalogApi.installed.mockResolvedValue([
{
catalogId: "team-no-deps",
catalogKey: "paperclipai/bundled/company-defaults/team-no-deps",
present: true,
currentContentHash: "sha256:deadbeefdeadbeefdeadbeef",
installedOriginHashes: ["sha256:deadbeefdeadbeefdeadbeef"],
agentCount: 2,
outOfDate: false,
},
]);
await renderPage();
expect(document.body.textContent).toContain("Installed · 1");
expect(document.body.textContent).toContain("Installed");
expect(document.body.textContent).not.toContain("Update available");
expect(findButton("Re-install latest")).toBeTruthy();
});
});