fix: fix some displays and features

This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 2332b7f815
commit 3be55795a6
31 changed files with 3118 additions and 30 deletions

View File

@ -0,0 +1,297 @@
/**
* 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" });
});
});