[codex] Add teams catalog extraction (#7550)
Fixes #7551 ## Thinking Path > - Paperclip is the control plane for AI-agent companies, and reusable company/team setup is part of making those companies faster to launch. > - The teams catalog work introduces app-shipped team templates that can be browsed, previewed, and installed into a company. > - Catalog installation crosses several contracts: bundled package contents, shared API types, server import/install behavior, CLI workflows, and the board UI. > - Agents also need a safe path through catalog installs: scoped company selection, explicit source policy, approval fallback for agent creation, and preserved catalog provenance. > - This pull request extracts the completed teams catalog branch into one reviewable PR on top of `public-gh/master`. > - The benefit is a reusable teams catalog foundation with server, CLI, package, docs, and hidden UI surfaces kept in sync. ## What Changed - Added the `@paperclipai/teams-catalog` package with bundled/optional team definitions, generated manifest, validators, catalog builder tests, and migration notes. - Added shared teams catalog types/validators plus server routes and services for listing, previewing, and installing catalog teams. - Integrated catalog install with company portability, skill/source policy checks, provenance metadata, origin hashes, target-manager reparenting, and installed/out-of-date detection. - Added CLI `teams` commands and agent-safe company selection behavior, including `company current` and approval fallback for forbidden agent-run installs. - Added hidden Team Catalog UI/API/query surfaces, Storybook fixtures, and targeted UI tests while keeping the UI route out of primary navigation. - Added docs for CLI/company/teams catalog behavior and removed generated screenshot artifacts from the PR diff. ## Verification - `pnpm exec vitest run cli/src/__tests__/company.test.ts cli/src/__tests__/teams.test.ts packages/teams-catalog/src/catalog-builder.test.ts packages/teams-catalog/src/shipped-catalog.test.ts server/src/__tests__/agent-permissions-service.test.ts server/src/__tests__/company-portability.test.ts server/src/__tests__/company-skills-service.test.ts server/src/__tests__/teams-catalog-routes.test.ts server/src/__tests__/teams-catalog-service.test.ts server/src/__tests__/teams-catalog-install-no-overrides.test.ts ui/src/lib/company-routes.test.ts ui/src/pages/TeamCard.test.tsx ui/src/pages/TeamCatalog.test.tsx ui/src/pages/useInstallTeamCatalogEntry.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/teams-catalog typecheck && pnpm --filter paperclipai typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - Confirmed branch is rebased onto `public-gh/master` (`78dc3625a`) and `public-gh/master` is an ancestor of `HEAD`. - Confirmed PR diff excludes `pnpm-lock.yaml`, `.github/workflows/*`, generated screenshot images, and screenshot helper scripts. ## Risks - Medium review surface: this crosses package generation, shared contracts, server install behavior, CLI, docs, and hidden UI code. - Catalog install behavior creates agents/projects/tasks/skills and must keep company scoping, permissions, source policy, and provenance checks strict. - `pnpm-lock.yaml` is intentionally excluded per repo policy; CI/default-branch automation owns lockfile refresh. - The Team Catalog UI is included but hidden from primary navigation, so future enablement should re-check visual QA before exposure. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. > > ROADMAP checked: this aligns with reusable companies/templates and plugin-adjacent onboarding work. This PR packages work already developed on the Paperclip task branch for review. ## Model Used - OpenAI Codex, GPT-5 series coding agent in this Paperclip session; exact runtime context window was not exposed. Used shell, git, `gh`, and local test/typecheck tooling. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why screenshots are intentionally omitted - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
CatalogTeam,
|
||||
CatalogTeamFileDetail,
|
||||
CatalogTeamImportOptions,
|
||||
CatalogTeamImportPreviewResult,
|
||||
CatalogTeamInstallOptions,
|
||||
CatalogTeamInstallResult,
|
||||
CatalogTeamKind,
|
||||
InstalledCatalogTeam,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface TeamCatalogListQuery {
|
||||
kind?: CatalogTeamKind;
|
||||
category?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the Phase E teams-catalog API (server/src/routes/teams-catalog.ts).
|
||||
*
|
||||
* The preview/install bodies mirror `catalogTeamPreviewSchema` /
|
||||
* `catalogTeamInstallSchema` exactly. Several richer fields the Phase F design
|
||||
* imagined (per-source policy maps, skill-plan overrides) are not
|
||||
* accepted by the shipped strict schema, so the UI derives those affordances
|
||||
* client-side and degrades gracefully — see TeamCatalog.tsx.
|
||||
*/
|
||||
export const teamCatalogApi = {
|
||||
catalogList: (query: TeamCatalogListQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.kind) params.set("kind", query.kind);
|
||||
if (query.category) params.set("category", query.category);
|
||||
if (query.q) params.set("q", query.q);
|
||||
const search = params.toString();
|
||||
return api.get<CatalogTeam[]>(`/teams/catalog${search ? `?${search}` : ""}`);
|
||||
},
|
||||
catalogDetail: (catalogRef: string) =>
|
||||
api.get<CatalogTeam>(`/teams/catalog/${encodeURIComponent(catalogRef)}`),
|
||||
installed: (companyId: string) =>
|
||||
api.get<InstalledCatalogTeam[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/teams/catalog/installed`,
|
||||
),
|
||||
catalogFile: (catalogRef: string, relativePath = "TEAM.md") =>
|
||||
api.get<CatalogTeamFileDetail>(
|
||||
`/teams/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`,
|
||||
),
|
||||
preview: (companyId: string, catalogRef: string, options: CatalogTeamImportOptions = {}) =>
|
||||
api.post<CatalogTeamImportPreviewResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/teams/catalog/${encodeURIComponent(catalogRef)}/preview`,
|
||||
options,
|
||||
),
|
||||
install: (companyId: string, catalogRef: string, options: CatalogTeamInstallOptions = {}) =>
|
||||
api.post<CatalogTeamInstallResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/teams/catalog/${encodeURIComponent(catalogRef)}/install`,
|
||||
options,
|
||||
),
|
||||
};
|
||||
@@ -35,4 +35,33 @@ describe("company routes", () => {
|
||||
expect(applyCompanyPrefix("/search?q=hello%20world", "PAP")).toBe("/PAP/search?q=hello%20world");
|
||||
expect(toCompanyRelativePath("/PAP/search?q=foo")).toBe("/search?q=foo");
|
||||
});
|
||||
|
||||
// Regression for PAP-10257: Team Catalog navigation (auto-select + row/file
|
||||
// clicks) produces company-relative `/teams-catalog/<key>` paths. Without
|
||||
// `teams-catalog` in the board-route allowlist, `extractCompanyPrefixFromPath`
|
||||
// misread the first segment as a company prefix and `useNavigate` skipped the
|
||||
// rewrite, dropping the `/PAP/` prefix and crashing into "Company not found".
|
||||
it("re-prefixes team catalog routes so navigate preserves the company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/teams")).toBe(false);
|
||||
expect(isBoardPathWithoutPrefix("/teams-catalog")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/teams-catalog/core-exec-team")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/teams-catalog/core-exec-team")).toBeNull();
|
||||
|
||||
// Auto-select effect: `/teams-catalog/<first-key>` must gain the `/PAP/` prefix.
|
||||
expect(applyCompanyPrefix("/teams-catalog/core-exec-team", "PAP")).toBe(
|
||||
"/PAP/teams-catalog/core-exec-team",
|
||||
);
|
||||
// File-tree click: nested `/files/<encoded>` path is preserved under the prefix.
|
||||
expect(applyCompanyPrefix("/teams-catalog/core-exec-team/files/TEAM.md", "PAP")).toBe(
|
||||
"/PAP/teams-catalog/core-exec-team/files/TEAM.md",
|
||||
);
|
||||
// Already-prefixed paths are left untouched (idempotent — no double prefix).
|
||||
expect(applyCompanyPrefix("/PAP/teams-catalog/core-exec-team", "PAP")).toBe(
|
||||
"/PAP/teams-catalog/core-exec-team",
|
||||
);
|
||||
// Round-trips back to a company-relative path.
|
||||
expect(toCompanyRelativePath("/PAP/teams-catalog/core-exec-team")).toBe(
|
||||
"/teams-catalog/core-exec-team",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"companies",
|
||||
"company",
|
||||
"skills",
|
||||
"teams-catalog",
|
||||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
|
||||
@@ -17,6 +17,14 @@ export const queryKeys = {
|
||||
catalogFile: (catalogRef: string, relativePath: string) =>
|
||||
["company-skills", "catalog", "file", catalogRef, relativePath] as const,
|
||||
},
|
||||
teamCatalog: {
|
||||
catalog: (filters: { kind?: string; category?: string; q?: string } = {}) =>
|
||||
["team-catalog", "catalog", filters.kind ?? "__all-kinds__", filters.category ?? "__all-categories__", filters.q ?? ""] as const,
|
||||
catalogDetail: (catalogRef: string) => ["team-catalog", "catalog", "detail", catalogRef] as const,
|
||||
catalogFile: (catalogRef: string, relativePath: string) =>
|
||||
["team-catalog", "catalog", "file", catalogRef, relativePath] as const,
|
||||
installed: (companyId: string) => ["team-catalog", "installed", companyId] as const,
|
||||
},
|
||||
agents: {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
|
||||
@@ -126,6 +126,25 @@ import { Identity } from "@/components/Identity";
|
||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||
import { MembershipAction } from "@/components/MembershipAction";
|
||||
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
|
||||
import {
|
||||
EnvInputsList,
|
||||
ExternalSourcesList,
|
||||
RequiredSkillsList,
|
||||
StepSkillPlan,
|
||||
StepSourcePolicy,
|
||||
TeamCard,
|
||||
TeamHierarchyPreview,
|
||||
TeamRow,
|
||||
} from "@/pages/TeamCatalog";
|
||||
import {
|
||||
currentInstalledState,
|
||||
onboardingTeams,
|
||||
optionalTeam,
|
||||
outOfDateInstalledState,
|
||||
sampleSkillPreparations,
|
||||
sampleTeam,
|
||||
warnTeam,
|
||||
} from "@/pages/TeamCatalog.fixtures";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -221,6 +240,24 @@ function SubSection({ title, children }: { title: string; children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Onboarding seam (design §6 + §12.5): the TeamCard tile in its "Pick a starter
|
||||
// team" 3-col grid, with the first defaultInstall tile selected.
|
||||
function TeamCardShowcase() {
|
||||
const [selectedId, setSelectedId] = useState(onboardingTeams[0]?.id ?? null);
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{onboardingTeams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
selected={team.id === selectedId}
|
||||
onSelect={() => setSelectedId(team.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Color swatch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -259,6 +296,9 @@ export function DesignGuide() {
|
||||
{ key: "status", label: "Status", value: "Active" },
|
||||
{ key: "priority", label: "Priority", value: "High" },
|
||||
]);
|
||||
const [allowExternal, setAllowExternal] = useState(false);
|
||||
const [allowUnpinned, setAllowUnpinned] = useState(false);
|
||||
const [allowLocalPath, setAllowLocalPath] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-10 max-w-4xl">
|
||||
@@ -1417,6 +1457,94 @@ export function DesignGuide() {
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* ICON REFERENCE */}
|
||||
{/* ============================================================ */}
|
||||
{/* TEAM CATALOG */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Team Catalog">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Components from the Team Catalog browse/install surface (<code className="font-mono text-xs">/teams-catalog</code>).
|
||||
Fixtures are shared with the Storybook stories.
|
||||
</p>
|
||||
|
||||
<SubSection title="TeamRow (browse list)">
|
||||
<div className="w-[28rem] rounded-md border border-border">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Bundled · 1
|
||||
</div>
|
||||
<TeamRow team={sampleTeam} selected onSelect={() => {}} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Optional · 2
|
||||
</div>
|
||||
<TeamRow team={optionalTeam} selected={false} onSelect={() => {}} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Installed · 2
|
||||
</div>
|
||||
<TeamRow team={sampleTeam} selected={false} onSelect={() => {}} installed={outOfDateInstalledState} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={() => {}} installed={currentInstalledState} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Installed teams collapse under <code className="font-mono">INSTALLED · N</code>; an out-of-date
|
||||
install (server <code className="font-mono">originHash</code> ≠ catalog <code className="font-mono">contentHash</code>)
|
||||
shows the amber <code className="font-mono">↑</code> badge (PAP-10256).
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="TeamCard (onboarding grid)">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Square tile for the onboarding “Pick a starter team” grid. Selected tile gets{" "}
|
||||
<code className="font-mono">ring-2 ring-ring</code>. Drives the{" "}
|
||||
<code className="font-mono">useInstallTeamCatalogEntry</code> simplified flow.
|
||||
</p>
|
||||
<TeamCardShowcase />
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="TeamHierarchyPreview">
|
||||
<div className="max-w-md">
|
||||
<TeamHierarchyPreview team={sampleTeam} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="RequiredSkillsList">
|
||||
<div className="max-w-xl">
|
||||
<RequiredSkillsList skills={sampleTeam.requiredSkills} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="EnvInputsList">
|
||||
<div className="max-w-xl">
|
||||
<EnvInputsList inputs={sampleTeam.envInputs} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="ExternalSourcesList">
|
||||
<div className="max-w-xl">
|
||||
<ExternalSourcesList sources={sampleTeam.sourceRefs} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Source policy step (StepSourcePolicy)">
|
||||
<div className="max-w-xl rounded-md border border-border p-4">
|
||||
<StepSourcePolicy
|
||||
team={warnTeam}
|
||||
allowExternalSources={allowExternal}
|
||||
allowUnpinnedOptionalSources={allowUnpinned}
|
||||
allowLocalPathSources={allowLocalPath}
|
||||
onChange={(key, value) => {
|
||||
if (key === "external") setAllowExternal(value);
|
||||
if (key === "unpinned") setAllowUnpinned(value);
|
||||
if (key === "localPath") setAllowLocalPath(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Skill plan step (StepSkillPlan)">
|
||||
<div className="max-w-xl rounded-md border border-border p-4">
|
||||
<StepSkillPlan team={sampleTeam} preparations={sampleSkillPreparations} />
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
<Section title="Common Icons (Lucide)">
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 gap-4">
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamCard } from "./TeamCatalog";
|
||||
import { onboardingTeams } from "./TeamCatalog.fixtures";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
return { container, cleanup: () => { root.unmount(); container.remove(); }, root };
|
||||
}
|
||||
|
||||
describe("TeamCard", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders name, pluralized counts, and tags", async () => {
|
||||
const team = onboardingTeams[0];
|
||||
const { container, root, cleanup } = render(null);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<TeamCard team={team} selected={false} onSelect={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain(team.name);
|
||||
expect(text).toContain(`${team.counts.agents} agents`);
|
||||
expect(text).toContain(`${team.counts.projects} project`);
|
||||
for (const tag of team.tags) expect(text).toContain(tag);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("applies the selection ring when selected and fires onSelect on click", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { container, root, cleanup } = render(null);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<TeamCard team={onboardingTeams[0]} selected onSelect={onSelect} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const button = container.querySelector("button")!;
|
||||
expect(button.className).toContain("ring-2");
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
await act(async () => {
|
||||
button.click();
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("hides the TrustChip for markdown_only teams", async () => {
|
||||
// onboardingTeams[0] is markdown_only — no trust chip text should appear.
|
||||
const { container, root, cleanup } = render(null);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<TeamCard team={onboardingTeams[0]} onSelect={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
// The "assets" team (index 1) does render a chip; sanity-check the contrast.
|
||||
expect(onboardingTeams[0].trustLevel).toBe("markdown_only");
|
||||
expect(onboardingTeams[1].trustLevel).toBe("assets");
|
||||
expect(container.querySelector("svg")).toBeTruthy(); // the Users2 icon still renders
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
CatalogTeam,
|
||||
CatalogTeamSkillPreparation,
|
||||
InstalledCatalogTeam,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Team Catalog fixtures.
|
||||
//
|
||||
// Used by both the Storybook stories (ui/storybook/stories/team-catalog.stories.tsx)
|
||||
// and the in-app /design-guide showcase so the two surfaces stay in sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sampleTeam: CatalogTeam = {
|
||||
id: "paperclipai:bundled:company-defaults:core-exec-team",
|
||||
key: "paperclipai/bundled/company-defaults/core-exec-team",
|
||||
kind: "bundled",
|
||||
category: "company-defaults",
|
||||
slug: "core-exec-team",
|
||||
name: "Core Exec Team",
|
||||
description:
|
||||
"A starter executive team: a CEO who manages a CTO and a CMO, plus a launch project and a weekly standup routine. Installs ready-to-run agents you can customize.",
|
||||
path: "catalog/bundled/company-defaults/core-exec-team",
|
||||
entrypoint: "TEAM.md",
|
||||
schema: "agentcompanies/v1",
|
||||
defaultInstall: true,
|
||||
recommendedForCompanyTypes: ["company-root"],
|
||||
tags: ["exec", "starter"],
|
||||
counts: {
|
||||
agents: 3,
|
||||
projects: 1,
|
||||
tasks: 1,
|
||||
routines: 1,
|
||||
localSkills: 1,
|
||||
catalogSkills: 1,
|
||||
externalSkillSources: 1,
|
||||
},
|
||||
rootAgentSlugs: ["ceo"],
|
||||
agentSlugs: ["ceo", "cto", "cmo"],
|
||||
projectSlugs: ["launch"],
|
||||
requiredSkills: [
|
||||
{ type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], resolved: true, catalogSkillKey: "engineering/code-review" },
|
||||
{ type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], resolved: false, sourceRef: "v1.2.0" },
|
||||
],
|
||||
envInputs: [
|
||||
{ key: "OPENAI_API_KEY", agentSlug: "cto", projectSlug: null, kind: "secret", requirement: "required" },
|
||||
{ key: "DEFAULT_TIMEZONE", agentSlug: null, projectSlug: "launch", kind: "plain", requirement: "optional" },
|
||||
],
|
||||
sourceRefs: [
|
||||
{ type: "github", ref: "acme/growth-playbook@v1.2.0", pinned: true },
|
||||
{ type: "url", ref: "https://example.com/policies/brand.md", pinned: false },
|
||||
],
|
||||
files: [
|
||||
{ path: "TEAM.md", kind: "team", sizeBytes: 2144, sha256: "a1" },
|
||||
{ path: "README.md", kind: "readme", sizeBytes: 980, sha256: "a2" },
|
||||
{ path: "agents/ceo/AGENTS.md", kind: "agent", sizeBytes: 1200, sha256: "a3" },
|
||||
{ path: "agents/cto/AGENTS.md", kind: "agent", sizeBytes: 1100, sha256: "a4" },
|
||||
{ path: "agents/cmo/AGENTS.md", kind: "agent", sizeBytes: 1050, sha256: "a5" },
|
||||
{ path: "projects/launch/PROJECT.md", kind: "project", sizeBytes: 640, sha256: "a6" },
|
||||
],
|
||||
trustLevel: "external_sources",
|
||||
compatibility: "compatible",
|
||||
contentHash: "sha256:deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
packageName: "@paperclipai/teams-catalog",
|
||||
packageVersion: "0.1.0",
|
||||
};
|
||||
|
||||
export const optionalTeam: CatalogTeam = {
|
||||
...sampleTeam,
|
||||
id: "paperclipai:optional:software-development:platform-pod",
|
||||
key: "paperclipai/optional/software-development/platform-pod",
|
||||
kind: "optional",
|
||||
category: "software-development",
|
||||
slug: "platform-pod",
|
||||
name: "Platform Engineering Pod",
|
||||
description: "An optional platform pod with a tech lead and two engineers.",
|
||||
recommendedForCompanyTypes: [],
|
||||
counts: { ...sampleTeam.counts, agents: 4, routines: 2 },
|
||||
rootAgentSlugs: ["tech-lead"],
|
||||
agentSlugs: ["tech-lead", "eng-1", "eng-2", "sre"],
|
||||
trustLevel: "markdown_only",
|
||||
sourceRefs: [],
|
||||
};
|
||||
|
||||
export const warnTeam: CatalogTeam = {
|
||||
...sampleTeam,
|
||||
id: "paperclipai:optional:research:lab-with-local-source",
|
||||
slug: "lab-with-local-source",
|
||||
name: "Research Lab (local source)",
|
||||
kind: "optional",
|
||||
category: "research",
|
||||
trustLevel: "scripts_executables",
|
||||
sourceRefs: [
|
||||
{ type: "url", ref: "https://example.com/unpinned.md", pinned: false },
|
||||
{ type: "local_path", ref: "/Users/dev/skills/secret-sauce", pinned: false },
|
||||
],
|
||||
};
|
||||
|
||||
// Server-computed installed-team state (PAP-10256). Drives the `INSTALLED · N`
|
||||
// group, the per-row out-of-date badge, and the detail header chip. Shared by
|
||||
// the Storybook stories and /design-guide showcase so they stay in sync.
|
||||
export const outOfDateInstalledState: InstalledCatalogTeam = {
|
||||
catalogId: sampleTeam.id,
|
||||
catalogKey: sampleTeam.key,
|
||||
present: true,
|
||||
currentContentHash: sampleTeam.contentHash,
|
||||
installedOriginHashes: ["sha256:0000older0000older0000older"],
|
||||
agentCount: 3,
|
||||
outOfDate: true,
|
||||
};
|
||||
|
||||
export const currentInstalledState: InstalledCatalogTeam = {
|
||||
...outOfDateInstalledState,
|
||||
installedOriginHashes: [sampleTeam.contentHash],
|
||||
outOfDate: false,
|
||||
};
|
||||
|
||||
export const sampleSkillPreparations: CatalogTeamSkillPreparation[] = [
|
||||
{ type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null },
|
||||
{ type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" },
|
||||
];
|
||||
|
||||
// Onboarding "Pick a starter team" grid (design §6): `defaultInstall` bundled
|
||||
// teams restricted to markdown_only/assets trust. Shared by the TeamCard
|
||||
// Storybook fixture and the /design-guide showcase so they stay in sync.
|
||||
export const onboardingTeams: CatalogTeam[] = [
|
||||
{
|
||||
...sampleTeam,
|
||||
trustLevel: "markdown_only",
|
||||
sourceRefs: [],
|
||||
requiredSkills: [],
|
||||
counts: { ...sampleTeam.counts, localSkills: 0, catalogSkills: 0, externalSkillSources: 0 },
|
||||
},
|
||||
{
|
||||
...sampleTeam,
|
||||
id: "paperclipai:bundled:company-defaults:growth-pod",
|
||||
key: "paperclipai/bundled/company-defaults/growth-pod",
|
||||
slug: "growth-pod",
|
||||
name: "Growth Pod",
|
||||
description:
|
||||
"A lean growth squad: a head of growth managing a content marketer and a data analyst, wired to a launch project and a weekly metrics routine.",
|
||||
tags: ["growth", "marketing", "starter"],
|
||||
counts: { agents: 3, projects: 1, tasks: 0, routines: 1, localSkills: 0, catalogSkills: 0, externalSkillSources: 0 },
|
||||
rootAgentSlugs: ["head-of-growth"],
|
||||
agentSlugs: ["head-of-growth", "content-marketer", "data-analyst"],
|
||||
trustLevel: "assets",
|
||||
sourceRefs: [],
|
||||
requiredSkills: [],
|
||||
},
|
||||
{
|
||||
...sampleTeam,
|
||||
id: "paperclipai:bundled:company-defaults:support-pod",
|
||||
key: "paperclipai/bundled/company-defaults/support-pod",
|
||||
slug: "support-pod",
|
||||
name: "Support Pod",
|
||||
description: "A two-person support desk with a lead and an agent, plus a triage routine.",
|
||||
tags: ["support", "ops"],
|
||||
counts: { agents: 2, projects: 0, tasks: 0, routines: 1, localSkills: 0, catalogSkills: 0, externalSkillSources: 0 },
|
||||
rootAgentSlugs: ["support-lead"],
|
||||
agentSlugs: ["support-lead", "support-agent"],
|
||||
projectSlugs: [],
|
||||
trustLevel: "markdown_only",
|
||||
sourceRefs: [],
|
||||
requiredSkills: [],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,453 @@
|
||||
// @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, parseTeamRoute, 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());
|
||||
|
||||
vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi }));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(() => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EMPTY_INSTALL_FORM,
|
||||
useInstallTeamCatalogEntry,
|
||||
type TeamInstallFormState,
|
||||
type UseInstallTeamCatalogEntryResult,
|
||||
} from "./TeamCatalog";
|
||||
import { sampleTeam } from "./TeamCatalog.fixtures";
|
||||
|
||||
const mockTeamCatalogApi = vi.hoisted(() => ({
|
||||
catalogList: vi.fn(),
|
||||
catalogDetail: vi.fn(),
|
||||
catalogFile: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
install: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// Capture the live hook result so assertions can read it and drive its actions.
|
||||
let captured: UseInstallTeamCatalogEntryResult | null = null;
|
||||
|
||||
function Harness({ simplified }: { simplified: boolean }) {
|
||||
captured = useInstallTeamCatalogEntry({
|
||||
companyId: "company-1",
|
||||
team: sampleTeam,
|
||||
simplified,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderHook(simplified: boolean) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness simplified={simplified} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return () => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
};
|
||||
}
|
||||
|
||||
describe("useInstallTeamCatalogEntry", () => {
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
mockTeamCatalogApi.preview.mockResolvedValue({});
|
||||
mockTeamCatalogApi.install.mockResolvedValue({ portabilityImport: {}, skillPreparations: [], warnings: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("standard flow surfaces the target-manager and source-policy steps", async () => {
|
||||
// sampleTeam has root agents + an unpinned external source.
|
||||
const cleanup = await renderHook(false);
|
||||
expect(captured!.steps).toContain("target_manager");
|
||||
expect(captured!.steps).toContain("source_policy");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("simplified flow drops target-manager and source-policy steps", async () => {
|
||||
const cleanup = await renderHook(true);
|
||||
expect(captured!.steps).not.toContain("target_manager");
|
||||
expect(captured!.steps).not.toContain("source_policy");
|
||||
// Required skills still resolve, preview is always last.
|
||||
expect(captured!.steps[captured!.steps.length - 1]).toBe("preview");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("simplified flow forces a full-company-equivalent target (null manager)", async () => {
|
||||
const cleanup = await renderHook(true);
|
||||
const form: TeamInstallFormState = {
|
||||
...EMPTY_INSTALL_FORM,
|
||||
targetManagerAgentId: "agent-should-be-ignored",
|
||||
};
|
||||
expect(captured!.buildPreviewOptions(form).targetManagerAgentId).toBeNull();
|
||||
expect(captured!.buildInstallOptions(form).targetManagerAgentId).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("standard flow honors the chosen target manager and adapter overrides", async () => {
|
||||
const cleanup = await renderHook(false);
|
||||
const form: TeamInstallFormState = {
|
||||
...EMPTY_INSTALL_FORM,
|
||||
targetManagerAgentId: "agent-7",
|
||||
adapterOverrides: { ceo: "codex_local" },
|
||||
};
|
||||
const preview = captured!.buildPreviewOptions(form);
|
||||
expect(preview.targetManagerAgentId).toBe("agent-7");
|
||||
const install = captured!.buildInstallOptions(form);
|
||||
expect(install.adapterOverrides).toEqual({ ceo: { adapterType: "codex_local" } });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("runInstall calls the install API and resolves to the done phase", async () => {
|
||||
const cleanup = await renderHook(true);
|
||||
await act(async () => {
|
||||
captured!.runInstall(EMPTY_INSTALL_FORM);
|
||||
});
|
||||
await flushReact();
|
||||
expect(mockTeamCatalogApi.install).toHaveBeenCalledTimes(1);
|
||||
expect(mockTeamCatalogApi.install).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
sampleTeam.id,
|
||||
expect.objectContaining({ targetManagerAgentId: null }),
|
||||
);
|
||||
expect(captured!.phase).toBe("done");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user