/** * L12 — skills feature + agent assignment, wired to the stateful * `MockSkillGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`. * * Covers: * - createSkill (project + global) → skill appears under the right scope * - updateSkill → content persisted in the gateway * - deleteSkill → skill removed from the list * - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record * - guardrail "no direct invoke": SkillsPanel never offers a run/launch action * * Also includes MockSkillGateway unit tests. */ import { describe, it, expect } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MockAgentGateway, MockProfileGateway, MockSkillGateway, } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { SkillsPanel } from "./SkillsPanel"; import { AgentsPanel } from "@/features/agents/AgentsPanel"; const PROJECT_ID = "proj-skill-test"; function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) { const a = agent ?? new MockAgentGateway(); const sk = skill ?? new MockSkillGateway(a); const gateways = { agent: a, skill: sk } as unknown as Gateways; return { agent: a, skill: sk, ...render( , ), }; } function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) { const profile = new MockProfileGateway(); const gateways = { agent, profile, skill } as unknown as Gateways; return render( , ); } async function waitForSkillsIdle() { await waitFor(() => { expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy(); }); } /** Opens the SkillEditor, fills it, and saves. */ async function createSkill( name: string, content = "# Workflow", scope: "project" | "global" = "project", ) { await waitForSkillsIdle(); fireEvent.click(screen.getByRole("button", { name: "create skill" })); await waitFor(() => expect(screen.getByLabelText("skill name")).toBeTruthy()); fireEvent.change(screen.getByLabelText("skill name"), { target: { value: name }, }); fireEvent.change(screen.getByLabelText("skill content"), { target: { value: content }, }); fireEvent.change(screen.getByLabelText("skill scope"), { target: { value: scope }, }); fireEvent.click(screen.getByRole("button", { name: "Save skill" })); } // --------------------------------------------------------------------------- // SkillsPanel feature tests // --------------------------------------------------------------------------- describe("SkillsPanel (with MockSkillGateway)", () => { it("shows empty state for both scopes initially", async () => { renderSkillsPanel(); await waitForSkillsIdle(); expect(screen.getByText("No project skills yet.")).toBeTruthy(); expect(screen.getByText("No global skills yet.")).toBeTruthy(); }); it("creating a project skill adds it to the list", async () => { const { skill } = renderSkillsPanel(); await createSkill("Refactor", "## steps", "project"); await screen.findByText("Refactor"); const projects = await skill.listSkills(PROJECT_ID, "project"); expect(projects).toHaveLength(1); expect(projects[0].name).toBe("Refactor"); }); it("creating a global skill stores it under the global scope", async () => { const { skill } = renderSkillsPanel(); await createSkill("Review", "## global", "global"); await screen.findByText("Review"); expect(await skill.listSkills(PROJECT_ID, "global")).toHaveLength(1); expect(await skill.listSkills(PROJECT_ID, "project")).toHaveLength(0); }); it("Save is disabled when name or content is empty", async () => { renderSkillsPanel(); await waitForSkillsIdle(); fireEvent.click(screen.getByRole("button", { name: "create skill" })); await waitFor(() => expect(screen.getByRole("button", { name: "Save skill" })).toBeTruthy(), ); const save = screen.getByRole("button", { name: "Save skill", }) as HTMLButtonElement; expect(save.disabled).toBe(true); }); it("editing a skill persists the new content", async () => { const { skill } = renderSkillsPanel(); await createSkill("Editable", "old", "project"); await screen.findByText("Editable"); fireEvent.click(screen.getByRole("button", { name: "edit skill Editable" })); await waitFor(() => expect(screen.getByLabelText("skill content")).toBeTruthy(), ); fireEvent.change(screen.getByLabelText("skill content"), { target: { value: "new content" }, }); fireEvent.click(screen.getByRole("button", { name: "Save skill" })); await waitFor(async () => { const list = await skill.listSkills(PROJECT_ID, "project"); expect(list[0].contentMd).toBe("new content"); }); }); it("deleting a skill removes it from the list", async () => { renderSkillsPanel(); await createSkill("ToDelete", "x", "project"); await screen.findByText("ToDelete"); fireEvent.click( screen.getByRole("button", { name: "delete skill ToDelete" }), ); await waitFor(() => expect(screen.queryByText("ToDelete")).toBeNull()); }); it("guardrail: never offers a run/launch action for a skill (no direct invoke)", async () => { renderSkillsPanel(); await createSkill("Plain", "x", "project"); await screen.findByText("Plain"); // Only Edit/Delete are exposed; nothing that would "run" the skill. expect(screen.queryByRole("button", { name: /^run/i })).toBeNull(); expect(screen.queryByRole("button", { name: /^launch/i })).toBeNull(); expect(screen.queryByRole("button", { name: /invoke/i })).toBeNull(); }); }); // --------------------------------------------------------------------------- // Assignment integration (AgentsPanel) // --------------------------------------------------------------------------- describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => { it("assigns a skill to the selected agent and shows it as a chip", async () => { const agent = new MockAgentGateway(); const skill = new MockSkillGateway(agent); const created = await agent.createAgent(PROJECT_ID, { name: "Worker", profileId: "p1", }); await skill.createSkill({ projectId: PROJECT_ID, name: "Deploy", content: "## deploy", scope: "project", }); renderAgentsPanel(agent, skill); await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy()); // Select the agent fireEvent.click(screen.getByText("Worker")); await waitFor(() => expect(screen.getByLabelText("skill to assign")).toBeTruthy(), ); // Choose the skill and assign const sk = (await skill.listSkills(PROJECT_ID, "project"))[0]; fireEvent.change(screen.getByLabelText("skill to assign"), { target: { value: sk.id }, }); fireEvent.click(screen.getByRole("button", { name: "assign skill" })); // The agent record now carries the skill ref await waitFor(async () => { const agents = await agent.listAgents(PROJECT_ID); const a = agents.find((x) => x.id === created.id)!; expect(a.skills.map((s) => s.skillId)).toContain(sk.id); }); // Chip with an unassign control appears expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy(); }); it("unassigns a skill from the agent", async () => { const agent = new MockAgentGateway(); const skill = new MockSkillGateway(agent); const a = await agent.createAgent(PROJECT_ID, { name: "Worker2", profileId: "p1", }); const sk = await skill.createSkill({ projectId: PROJECT_ID, name: "Cleanup", content: "## cleanup", scope: "global", }); await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope); renderAgentsPanel(agent, skill); await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy()); fireEvent.click(screen.getByText("Worker2")); await waitFor(() => expect( screen.getByRole("button", { name: "unassign Cleanup" }), ).toBeTruthy(), ); fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" })); await waitFor(async () => { const agents = await agent.listAgents(PROJECT_ID); expect(agents.find((x) => x.id === a.id)!.skills).toHaveLength(0); }); }); }); // --------------------------------------------------------------------------- // MockSkillGateway unit tests // --------------------------------------------------------------------------- describe("MockSkillGateway (unit)", () => { it("scopes are isolated: a project skill never appears in global and vice-versa", async () => { const gw = new MockSkillGateway(new MockAgentGateway()); await gw.createSkill({ projectId: "p", name: "P", content: "x", scope: "project", }); await gw.createSkill({ projectId: "p", name: "G", content: "y", scope: "global", }); expect(await gw.listSkills("p", "project")).toHaveLength(1); expect(await gw.listSkills("p", "global")).toHaveLength(1); expect((await gw.listSkills("p", "project"))[0].name).toBe("P"); expect((await gw.listSkills("p", "global"))[0].name).toBe("G"); }); it("updateSkill throws NOT_FOUND for unknown skill", async () => { const gw = new MockSkillGateway(new MockAgentGateway()); await expect( gw.updateSkill("p", "project", "ghost", "x"), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); it("assignSkill is idempotent (no duplicate refs)", async () => { const agentGw = new MockAgentGateway(); const gw = new MockSkillGateway(agentGw); const a = await agentGw.createAgent("p", { name: "A", profileId: "p1" }); const s = await gw.createSkill({ projectId: "p", name: "S", content: "x", scope: "project", }); await gw.assignSkill("p", a.id, s.id, s.scope); await gw.assignSkill("p", a.id, s.id, s.scope); const agents = await agentGw.listAgents("p"); expect(agents[0].skills).toHaveLength(1); }); it("assignSkill throws NOT_FOUND for unknown agent", async () => { const gw = new MockSkillGateway(new MockAgentGateway()); await expect( gw.assignSkill("p", "ghost", "s", "project"), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); });