mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:31:54 +01:00
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 <noreply@anthropic.com> Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
30dc2bbc60
commit
9dc43eed42
3 files changed
+377
No files matched your search
@@ -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,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: [],
|
||||
},
|
||||
];
|
||||
@@ -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