298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
/**
|
|
* 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(
|
|
<DIProvider gateways={gateways}>
|
|
<SkillsPanel projectId={PROJECT_ID} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
|
const profile = new MockProfileGateway();
|
|
const gateways = { agent, profile, skill } as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
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" });
|
|
});
|
|
});
|