Files
IdeA/frontend/src/features/templates/templates.test.tsx

696 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
MockPermissionGateway,
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;
permission?: MockPermissionGateway;
}
/**
* 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 permission = opts.permission ?? new MockPermissionGateway();
const gateways = { agent, permission, profile, template: tmpl } as unknown as Gateways;
return {
agent,
tmpl,
profile,
permission,
...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("creates a template with default agent name, model and permissions summaries", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: "Main template" },
});
fireEvent.change(screen.getByLabelText("template content"), {
target: { value: "# Main instructions" },
});
fireEvent.change(screen.getByLabelText("template default agent name"), {
target: { value: "Main" },
});
fireEvent.change(screen.getByLabelText("template default profile"), {
target: { value: "profile-main" },
});
chooseDropdownOption("template system network permission", "Réseau autorisé");
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read"));
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
await screen.findByText("Main template");
expect(screen.getByText("agent initial: Main")).toBeTruthy();
expect(screen.getByText("modèle: Profil profile-main")).toBeTruthy();
expect(screen.getByText(/Système: réseau autorisé/)).toBeTruthy();
expect(screen.getByText(/MCP: Lire un ticket/)).toBeTruthy();
const templates = await tmpl.listTemplates();
expect(templates[0]).toMatchObject({
defaultAgentName: "Main",
defaultProfileId: "profile-main",
defaultContactable: true,
systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
});
it("preserves defaultContactable after save and reopen", async () => {
const { tmpl } = renderTemplatesPanel();
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await screen.findByLabelText("template name");
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: "Contactable" },
});
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
await screen.findByText("Contactable");
expect((await tmpl.listTemplates())[0]).toMatchObject({
defaultContactable: true,
systemPermissions: null,
});
fireEvent.click(screen.getByRole("button", { name: "edit Contactable" }));
await waitFor(() => {
expect(
(screen.getByLabelText(
"Contactable depuis d’autres projets",
) as HTMLInputElement).checked,
).toBe(true);
});
});
it("defaults template cross-project contactability off when the field is absent", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
const checkbox = screen.getByLabelText(
"Contactable depuis d’autres projets",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
expect(
screen.getByText(
"Les agents créés avec ce template reprendront cette permission.",
),
).toBeTruthy();
});
it("shows cross-project and ungrouped catalogued MCP tools in the template editor", async () => {
const permission = new MockPermissionGateway();
permission.setMcpToolCatalogue({
readOnlyTools: ["idea_project_list", "idea_future_read"],
writeActionTools: ["idea_project_cancel_agent"],
});
renderTemplatesPanel({ permission });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
expect(await screen.findByText("IdeA inter-projets")).toBeTruthy();
expect(
screen.getByLabelText("template MCP tool idea_project_cancel_agent"),
).toBeTruthy();
expect(screen.getByText("Autres")).toBeTruthy();
const fallback = screen.getByLabelText(
"template MCP tool idea_future_read",
) as HTMLInputElement;
expect(fallback.disabled).toBe(false);
});
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("reopening an edited template preserves saved MCP tool permissions in the editor", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
await tmpl.createTemplate({
name: "Permissioned",
content: "# Initial",
defaultProfileId: "",
mcpToolPermissions: null,
});
renderTemplatesPanel({ agent, template: tmpl });
await screen.findByText("Permissioned");
fireEvent.click(screen.getByRole("button", { name: "edit Permissioned" }));
await waitFor(() => {
expect(screen.getByLabelText("template MCP tool idea_ticket_read")).toBeTruthy();
});
const checkbox = screen.getByLabelText(
"template MCP tool idea_ticket_read",
) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
await waitFor(() => {
expect(screen.queryByLabelText("template editor")).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "edit Permissioned" }));
await waitFor(() => {
expect(screen.getByLabelText("template MCP tool idea_ticket_read")).toBeTruthy();
});
expect(
(screen.getByLabelText("template MCP tool idea_ticket_read") as HTMLInputElement).checked,
).toBe(true);
});
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("creating an agent from a template applies the displayed default name and profile", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
await tmpl.createTemplate({
name: "Worker template",
content: "## ctx",
defaultProfileId: "profile-worker",
defaultAgentName: "Worker",
});
renderTemplatesPanel({ agent, template: tmpl });
await screen.findByText("Worker template");
expect(screen.getByText("agent initial: Worker")).toBeTruthy();
expect(screen.getByText("modèle: Profil profile-worker")).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "create agent from Worker template" }),
);
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe("Worker");
expect(agents[0].profileId).toBe("profile-worker");
});
});
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" });
});
});