From 9dc43eed42c2097d7670318cb9866eccf570ccbd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 3 Jun 2026 19:21:12 +0000 Subject: [PATCH] PAP-10254 Team Catalog onboarding seam: shared fixtures + tests Add the onboardingTeams fixture (defaultInstall starter-team tiles) shared by the TeamCard Storybook fixture and the /design-guide showcase, plus unit tests covering TeamCard rendering/selection and the useInstallTeamCatalogEntry simplified-flow contract (no target-manager step, full-company-equivalent target, adapter overrides). The TeamCard component and useInstallTeamCatalogEntry hook themselves landed in ba9cf17a0 (TeamCatalog.tsx is shared with the concurrent secret-values work). The /design-guide entry and Storybook stories remain in the working tree atop the in-flight Phase G design-guide showcase on those same files. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Paperclip --- ui/src/pages/TeamCard.test.tsx | 88 +++++++++++ ui/src/pages/TeamCatalog.fixtures.ts | 146 ++++++++++++++++++ .../pages/useInstallTeamCatalogEntry.test.tsx | 143 +++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 ui/src/pages/TeamCard.test.tsx create mode 100644 ui/src/pages/TeamCatalog.fixtures.ts create mode 100644 ui/src/pages/useInstallTeamCatalogEntry.test.tsx diff --git a/ui/src/pages/TeamCard.test.tsx b/ui/src/pages/TeamCard.test.tsx new file mode 100644 index 00000000000..e36129d9c6f --- /dev/null +++ b/ui/src/pages/TeamCard.test.tsx @@ -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) { + let result: void | Promise = 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( + + {}} /> + , + ); + }); + 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( + + + , + ); + }); + 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( + + {}} /> + , + ); + }); + // 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(); + }); +}); diff --git a/ui/src/pages/TeamCatalog.fixtures.ts b/ui/src/pages/TeamCatalog.fixtures.ts new file mode 100644 index 00000000000..bf4f6f36272 --- /dev/null +++ b/ui/src/pages/TeamCatalog.fixtures.ts @@ -0,0 +1,146 @@ +import type { + CatalogTeam, + CatalogTeamSkillPreparation, +} 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 }, + ], +}; + +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: [], + }, +]; diff --git a/ui/src/pages/useInstallTeamCatalogEntry.test.tsx b/ui/src/pages/useInstallTeamCatalogEntry.test.tsx new file mode 100644 index 00000000000..4d38714eb34 --- /dev/null +++ b/ui/src/pages/useInstallTeamCatalogEntry.test.tsx @@ -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) { + let result: void | Promise = 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( + + + , + ); + }); + 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(); + }); +});