696 lines
25 KiB
TypeScript
696 lines
25 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,
|
||
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" });
|
||
});
|
||
});
|