Files
IdeaSDK/frontend/src/features/templates/templates.test.tsx
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

504 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();
});
}
/**
* 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) {
fireEvent.change(screen.getByLabelText("template default profile"), {
target: { value: 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" });
});
});