Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
/**
|
|
* L7 — templates feature + drift/sync, wired to the stateful
|
|
* `MockTemplateGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`.
|
|
*
|
|
* Covers:
|
|
* - createTemplate → template appears in list
|
|
* - updateTemplate → version increments
|
|
* - deleteTemplate → template removed from list
|
|
* - createAgentFromTemplate → agent appears in agent list
|
|
* - drift: after createAgentFromTemplate(synchronized:true) + updateTemplate →
|
|
* detectDrift returns the agent → badge "update available" shown in AgentsPanel
|
|
* - Sync: clicking the Sync button calls syncAgent → badge disappears
|
|
*
|
|
* Also includes MockTemplateGateway unit tests.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
|
|
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
|
|
import type { Gateways } from "@/ports";
|
|
import { DIProvider } from "@/app/di";
|
|
import { TemplatesPanel } from "./TemplatesPanel";
|
|
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PROJECT_ID = "proj-tmpl-test";
|
|
|
|
interface RenderOpts {
|
|
agent?: MockAgentGateway;
|
|
template?: MockTemplateGateway;
|
|
profile?: MockProfileGateway;
|
|
}
|
|
|
|
/**
|
|
* Renders `TemplatesPanel` behind a `DIProvider` with isolated mock gateways.
|
|
* Returns references to the gateway instances for direct inspection.
|
|
*/
|
|
function renderTemplatesPanel(opts: RenderOpts = {}) {
|
|
const agent = opts.agent ?? new MockAgentGateway();
|
|
const tmpl = opts.template ?? new MockTemplateGateway(agent);
|
|
const profile = opts.profile ?? new MockProfileGateway();
|
|
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
|
|
return {
|
|
agent,
|
|
tmpl,
|
|
profile,
|
|
...render(
|
|
<DIProvider gateways={gateways}>
|
|
<TemplatesPanel projectId={PROJECT_ID} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Renders `AgentsPanel` with the given shared gateways.
|
|
*/
|
|
function renderAgentsPanel(agent: MockAgentGateway, tmpl: MockTemplateGateway) {
|
|
const profile = new MockProfileGateway();
|
|
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Waits for the TemplatesPanel to be idle (the "New template" / "create template"
|
|
* button is accessible, meaning the panel has rendered).
|
|
*/
|
|
async function waitForTemplatesIdle() {
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy();
|
|
});
|
|
}
|
|
|
|
/** Waits for agents panel to be idle (agent name field accessible). */
|
|
async function waitForAgentsIdle() {
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("agent name")).toBeTruthy();
|
|
});
|
|
}
|
|
|
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
|
fireEvent.click(screen.getByLabelText(label));
|
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
|
}
|
|
|
|
/**
|
|
* Opens the TemplateEditor overlay, fills the create-template form, and saves it.
|
|
* Adapted for the new fullscreen-editor flow:
|
|
* 1. Click the "create template" ("New template") button to open the editor.
|
|
* 2. Fill `template name`, `template content`, optionally `template default profile`.
|
|
* 3. Click "Save template" to submit.
|
|
*/
|
|
async function createTemplate(
|
|
name: string,
|
|
content = "# Content",
|
|
profileId = "",
|
|
) {
|
|
await waitForTemplatesIdle();
|
|
|
|
// Open the fullscreen editor overlay
|
|
fireEvent.click(screen.getByRole("button", { name: "create template" }));
|
|
|
|
// Wait for the editor to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("template name")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.change(screen.getByLabelText("template name"), {
|
|
target: { value: name },
|
|
});
|
|
fireEvent.change(screen.getByLabelText("template content"), {
|
|
target: { value: content },
|
|
});
|
|
if (profileId) {
|
|
const profileField = screen.getByLabelText("template default profile");
|
|
if (profileField.tagName === "INPUT") {
|
|
fireEvent.change(profileField, { target: { value: profileId } });
|
|
} else {
|
|
chooseDropdownOption("template default profile", profileId);
|
|
}
|
|
}
|
|
|
|
// Save via the "Save template" button (aria-label on the submit button)
|
|
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TemplatesPanel feature tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|
it("shows 'No templates yet.' when there are no templates", async () => {
|
|
renderTemplatesPanel();
|
|
await waitForTemplatesIdle();
|
|
expect(screen.getByText("No templates yet.")).toBeTruthy();
|
|
});
|
|
|
|
it("creating a template adds it to the list", async () => {
|
|
renderTemplatesPanel();
|
|
await createTemplate("My Template", "## Hello");
|
|
const item = await screen.findByText("My Template");
|
|
expect(item).toBeTruthy();
|
|
// Version 1 shown
|
|
expect(screen.getByText("v1")).toBeTruthy();
|
|
});
|
|
|
|
it("the Create button is disabled when the name is empty", async () => {
|
|
renderTemplatesPanel();
|
|
await waitForTemplatesIdle();
|
|
// "New template" button should be enabled (opens the editor)
|
|
const btn = screen.getByRole("button", { name: "create template" });
|
|
// The "New template" button is always enabled — it opens the editor.
|
|
// The Save button *inside* the editor is disabled when name is empty.
|
|
fireEvent.click(btn);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Save template" })).toBeTruthy();
|
|
});
|
|
const saveBtn = screen.getByRole("button", { name: "Save template" });
|
|
expect((saveBtn as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it("updating a template increments its version", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
renderTemplatesPanel({ agent, template: tmpl });
|
|
|
|
await createTemplate("Versioned");
|
|
await screen.findByText("v1");
|
|
|
|
// Click Edit — opens the fullscreen editor for this template
|
|
fireEvent.click(screen.getByRole("button", { name: "edit Versioned" }));
|
|
|
|
// Wait for the editor to open
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("template content")).toBeTruthy();
|
|
});
|
|
|
|
// Edit the content and save
|
|
fireEvent.change(screen.getByLabelText("template content"), {
|
|
target: { value: "# Updated content" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
|
|
|
// Version should now be 2
|
|
await waitFor(() => {
|
|
expect(screen.getByText("v2")).toBeTruthy();
|
|
});
|
|
|
|
// Gateway reflects the update
|
|
const templates = await tmpl.listTemplates();
|
|
expect(templates[0].version).toBe(2);
|
|
expect(templates[0].contentMd).toBe("# Updated content");
|
|
});
|
|
|
|
it("deleting a template removes it from the list", async () => {
|
|
renderTemplatesPanel();
|
|
await createTemplate("ToDelete");
|
|
await screen.findByText("ToDelete");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "delete template ToDelete" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("ToDelete")).toBeNull();
|
|
});
|
|
expect(screen.getByText("No templates yet.")).toBeTruthy();
|
|
});
|
|
|
|
it("'Create agent from template' creates an agent in the shared agent gateway", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
renderTemplatesPanel({ agent, template: tmpl });
|
|
|
|
await createTemplate("Agent Factory", "## ctx", "p1");
|
|
await screen.findByText("Agent Factory");
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "create agent from Agent Factory" }),
|
|
);
|
|
|
|
// Verify the agent appears in the shared gateway
|
|
await waitFor(async () => {
|
|
const agents = await agent.listAgents(PROJECT_ID);
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0].origin.type).toBe("fromTemplate");
|
|
expect(agents[0].synchronized).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("TemplateEditor: can switch Edit/Preview tabs and Save persists the template", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
renderTemplatesPanel({ agent, template: tmpl });
|
|
|
|
await waitForTemplatesIdle();
|
|
|
|
// Open the editor overlay
|
|
fireEvent.click(screen.getByRole("button", { name: "create template" }));
|
|
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
|
|
|
|
// Fill in name and content in Edit tab
|
|
fireEvent.change(screen.getByLabelText("template name"), {
|
|
target: { value: "Preview Test" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText("template content"), {
|
|
target: { value: "## Hello Preview" },
|
|
});
|
|
|
|
// Switch to Preview tab
|
|
fireEvent.click(screen.getByRole("tab", { name: "Preview" }));
|
|
// The rendered markdown content should be visible (no textarea)
|
|
await waitFor(() => {
|
|
expect(screen.queryByLabelText("template content")).toBeNull();
|
|
});
|
|
|
|
// Switch back to Edit tab
|
|
fireEvent.click(screen.getByRole("tab", { name: "Edit" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("template content")).toBeTruthy();
|
|
});
|
|
|
|
// Save
|
|
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
|
|
|
// Template should appear in the list
|
|
await screen.findByText("Preview Test");
|
|
const templates = await tmpl.listTemplates();
|
|
expect(templates).toHaveLength(1);
|
|
expect(templates[0].name).toBe("Preview Test");
|
|
expect(templates[0].contentMd).toBe("## Hello Preview");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Drift + Sync integration tests (AgentsPanel)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Drift badge and Sync (AgentsPanel + MockTemplateGateway)", () => {
|
|
it("shows 'update available' badge after template is updated and agent has drift", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
|
|
// Create a template and an agent from it (synchronized)
|
|
const template = await tmpl.createTemplate({
|
|
name: "T1",
|
|
content: "# v1",
|
|
defaultProfileId: "p1",
|
|
});
|
|
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
|
|
name: "SyncedAgent",
|
|
synchronized: true,
|
|
});
|
|
|
|
// Update the template to produce drift
|
|
await tmpl.updateTemplate(template.id, "# v2 updated");
|
|
|
|
// Render AgentsPanel — drift should be detected on mount
|
|
renderAgentsPanel(agent, tmpl);
|
|
await waitForAgentsIdle();
|
|
|
|
// Badge should be visible
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("update available")).toBeTruthy();
|
|
});
|
|
expect(screen.getByRole("button", { name: "sync SyncedAgent" })).toBeTruthy();
|
|
});
|
|
|
|
it("clicking Sync removes the 'update available' badge", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
|
|
const template = await tmpl.createTemplate({
|
|
name: "T2",
|
|
content: "# v1",
|
|
defaultProfileId: "p1",
|
|
});
|
|
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
|
|
name: "DriftedAgent",
|
|
synchronized: true,
|
|
});
|
|
|
|
// Create drift
|
|
await tmpl.updateTemplate(template.id, "# v2 content");
|
|
|
|
renderAgentsPanel(agent, tmpl);
|
|
await waitForAgentsIdle();
|
|
|
|
// Badge should appear
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("update available")).toBeTruthy();
|
|
});
|
|
|
|
// Click Sync
|
|
fireEvent.click(screen.getByRole("button", { name: "sync DriftedAgent" }));
|
|
|
|
// Badge disappears after sync
|
|
await waitFor(() => {
|
|
expect(screen.queryByLabelText("update available")).toBeNull();
|
|
});
|
|
|
|
// Verify drift is empty at the gateway level
|
|
const drifts = await tmpl.detectDrift(PROJECT_ID);
|
|
expect(drifts).toHaveLength(0);
|
|
});
|
|
|
|
it("does not show badge for non-synchronized agents", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const tmpl = new MockTemplateGateway(agent);
|
|
|
|
const template = await tmpl.createTemplate({
|
|
name: "T3",
|
|
content: "# v1",
|
|
defaultProfileId: "p1",
|
|
});
|
|
// synchronized: false → no drift
|
|
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
|
|
name: "UnsyncedAgent",
|
|
synchronized: false,
|
|
});
|
|
|
|
await tmpl.updateTemplate(template.id, "# v2 content");
|
|
|
|
renderAgentsPanel(agent, tmpl);
|
|
await waitForAgentsIdle();
|
|
|
|
// Give it a moment to detect drift
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("UnsyncedAgent")).toBeTruthy();
|
|
});
|
|
|
|
// No badge
|
|
expect(screen.queryByLabelText("update available")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MockTemplateGateway unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MockTemplateGateway (unit)", () => {
|
|
it("listTemplates returns empty initially", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
expect(await gw.listTemplates()).toEqual([]);
|
|
});
|
|
|
|
it("createTemplate assigns sequential ids and version 1", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
const t1 = await gw.createTemplate({ name: "A", content: "a", defaultProfileId: "" });
|
|
const t2 = await gw.createTemplate({ name: "B", content: "b", defaultProfileId: "" });
|
|
expect(t1.id).toBe("mock-template-1");
|
|
expect(t2.id).toBe("mock-template-2");
|
|
expect(t1.version).toBe(1);
|
|
expect(t2.version).toBe(1);
|
|
});
|
|
|
|
it("updateTemplate increments version and stores new content", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
const t = await gw.createTemplate({ name: "T", content: "old", defaultProfileId: "" });
|
|
const updated = await gw.updateTemplate(t.id, "new content");
|
|
expect(updated.version).toBe(2);
|
|
expect(updated.contentMd).toBe("new content");
|
|
|
|
// Idempotent list
|
|
const list = await gw.listTemplates();
|
|
expect(list[0].version).toBe(2);
|
|
});
|
|
|
|
it("updateTemplate throws NOT_FOUND for unknown template", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
await expect(gw.updateTemplate("ghost", "x")).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
});
|
|
|
|
it("deleteTemplate removes the template", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
const t = await gw.createTemplate({ name: "Del", content: "x", defaultProfileId: "" });
|
|
await gw.deleteTemplate(t.id);
|
|
expect(await gw.listTemplates()).toHaveLength(0);
|
|
});
|
|
|
|
it("deleteTemplate throws NOT_FOUND for unknown template", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
await expect(gw.deleteTemplate("ghost")).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
});
|
|
|
|
it("createAgentFromTemplate creates an agent with fromTemplate origin", async () => {
|
|
const agentGw = new MockAgentGateway();
|
|
const tmplGw = new MockTemplateGateway(agentGw);
|
|
const t = await tmplGw.createTemplate({ name: "Proto", content: "## ctx", defaultProfileId: "p1" });
|
|
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
|
|
name: "Derived",
|
|
synchronized: true,
|
|
});
|
|
expect(agent.origin.type).toBe("fromTemplate");
|
|
if (agent.origin.type === "fromTemplate") {
|
|
expect(agent.origin.templateId).toBe(t.id);
|
|
expect(agent.origin.syncedTemplateVersion).toBe(1);
|
|
}
|
|
expect(agent.synchronized).toBe(true);
|
|
expect(agent.name).toBe("Derived");
|
|
|
|
// Agent appears in the shared agent gateway
|
|
const agents = await agentGw.listAgents("proj");
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0].id).toBe(agent.id);
|
|
});
|
|
|
|
it("detectDrift returns drift for synchronized agents with stale version", async () => {
|
|
const agentGw = new MockAgentGateway();
|
|
const tmplGw = new MockTemplateGateway(agentGw);
|
|
const t = await tmplGw.createTemplate({ name: "T", content: "v1", defaultProfileId: "" });
|
|
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
|
|
synchronized: true,
|
|
});
|
|
|
|
// No drift yet (versions match)
|
|
expect(await tmplGw.detectDrift("proj")).toHaveLength(0);
|
|
|
|
// Update template
|
|
await tmplGw.updateTemplate(t.id, "v2");
|
|
|
|
const drifts = await tmplGw.detectDrift("proj");
|
|
expect(drifts).toHaveLength(1);
|
|
expect(drifts[0]).toMatchObject({ agentId: agent.id, from: 1, to: 2 });
|
|
});
|
|
|
|
it("syncAgent updates syncedTemplateVersion and returns { synced: true, version }", async () => {
|
|
const agentGw = new MockAgentGateway();
|
|
const tmplGw = new MockTemplateGateway(agentGw);
|
|
const t = await tmplGw.createTemplate({ name: "T", content: "v1", defaultProfileId: "" });
|
|
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
|
|
synchronized: true,
|
|
});
|
|
await tmplGw.updateTemplate(t.id, "v2 content");
|
|
|
|
const result = await tmplGw.syncAgent("proj", agent.id);
|
|
expect(result).toEqual({ synced: true, version: 2 });
|
|
|
|
// No more drift
|
|
expect(await tmplGw.detectDrift("proj")).toHaveLength(0);
|
|
|
|
// Agent context updated
|
|
const ctx = await agentGw.readContext("proj", agent.id);
|
|
expect(ctx).toBe("v2 content");
|
|
});
|
|
|
|
it("syncAgent returns { synced: false, version: null } for scratch agents", async () => {
|
|
const agentGw = new MockAgentGateway();
|
|
const tmplGw = new MockTemplateGateway(agentGw);
|
|
const a = await agentGw.createAgent("proj", { name: "Scratch", profileId: "p" });
|
|
const result = await tmplGw.syncAgent("proj", a.id);
|
|
expect(result).toEqual({ synced: false, version: null });
|
|
});
|
|
|
|
it("createAgentFromTemplate throws NOT_FOUND for unknown template", async () => {
|
|
const gw = new MockTemplateGateway(new MockAgentGateway());
|
|
await expect(
|
|
gw.createAgentFromTemplate("proj", "ghost-template"),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
});
|
|
});
|