Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
504 lines
18 KiB
TypeScript
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" });
|
|
});
|
|
});
|