/** * 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( , ), }; } /** * 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( , ); } /** * 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" }); }); });