feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,386 @@
/**
* L6 — agents feature wired to the stateful `MockAgentGateway` (and a seeded
* `MockProfileGateway`) via the real `DIProvider`.
*
* Covers:
* - create → agent appears in list
* - select → context is shown; edit + save → re-reading shows new content
* - delete → agent removed from list
* - launch → `launchAgent` is called and mounts a terminal view; stop unmounts it
* - empty name → Create button disabled
* - MockAgentGateway unit behaviour: slug deduplication, NOT_FOUND errors
*/
import { describe, it, expect, vi } 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 { AgentsPanel } from "./AgentsPanel";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const PROJECT_ID = "proj-test-001";
/**
* Renders `AgentsPanel` behind a `DIProvider` with an isolated
* `MockAgentGateway` (and optionally a seeded `MockProfileGateway`).
*/
function renderPanel(
agent: MockAgentGateway = new MockAgentGateway(),
profile: MockProfileGateway = new MockProfileGateway(),
projectRoot = "/home/me/proj",
template?: MockTemplateGateway,
) {
const tmpl = template ?? new MockTemplateGateway(agent);
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
return {
agent,
profile,
template: tmpl,
...render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
</DIProvider>,
),
};
}
/** Waits until no Spinner is visible (busy → idle). */
async function waitForIdle() {
// The Create button is enabled when not busy and name is non-empty.
// We wait for the panel to stabilise by waiting for "No agents yet." or
// for the agent list to be rendered.
await waitFor(() => {
// Panel has finished its initial load when the form is accessible.
expect(screen.getByLabelText("agent name")).toBeTruthy();
});
}
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
async function createAgent(name: string, profileId?: string) {
await waitForIdle();
fireEvent.change(screen.getByLabelText("agent name"), {
target: { value: name },
});
if (profileId) {
const profileInput = screen.queryByLabelText("agent profile");
if (profileInput) {
fireEvent.change(profileInput, { target: { value: profileId } });
}
}
const createBtn = screen.getByRole("button", { name: "create agent" });
expect((createBtn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(createBtn);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AgentsPanel (with MockAgentGateway)", () => {
it("shows 'No agents yet.' when the project has no agents", async () => {
renderPanel();
await waitForIdle();
expect(screen.getByText("No agents yet.")).toBeTruthy();
});
it("creating an agent adds it to the list", async () => {
renderPanel();
await createAgent("My Worker");
const agent = await screen.findByText("My Worker");
expect(agent).toBeTruthy();
});
it("the Create button is disabled when the name is empty", async () => {
renderPanel();
await waitForIdle();
// Name field is empty by default → disabled
const btn = screen.getByRole("button", { name: "create agent" });
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("selecting an agent displays its context", async () => {
const agent = new MockAgentGateway();
// Pre-seed an agent with initial content.
await agent.createAgent(PROJECT_ID, {
name: "Alpha",
profileId: "p1",
initialContent: "## Alpha context",
});
renderPanel(agent);
await waitForIdle();
// Click on the agent row button (aria-pressed) to select it — not the
// "launch Alpha" action button.
const buttons = screen.getAllByRole("button", { name: /alpha/i });
const rowBtn = buttons.find(
(b) => b.hasAttribute("aria-pressed"),
)!;
fireEvent.click(rowBtn);
const textarea = await screen.findByLabelText("agent context");
expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
});
it("editing and saving context persists the new content", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Beta",
profileId: "p1",
initialContent: "initial",
});
renderPanel(agent);
await waitForIdle();
// Select the agent row button (aria-pressed), not the action buttons.
const buttons = screen.getAllByRole("button", { name: /beta/i });
const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
const textarea = await screen.findByLabelText("agent context");
fireEvent.change(textarea, { target: { value: "updated content" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// After save the gateway should hold the new content.
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
const ctx = await agent.readContext(PROJECT_ID, agents[0].id);
expect(ctx).toBe("updated content");
});
});
it("deleting an agent removes it from the list", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Gamma",
profileId: "p1",
});
renderPanel(agent);
await waitForIdle();
await screen.findByText("Gamma");
fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));
await waitFor(() => {
expect(screen.queryByText("Gamma")).toBeNull();
});
// Gateway-level check.
const remaining = await agent.listAgents(PROJECT_ID);
expect(remaining).toHaveLength(0);
});
it("launching an agent calls launchAgent and mounts the terminal view", async () => {
const agent = new MockAgentGateway();
const createdAgent = await agent.createAgent(PROJECT_ID, {
name: "Delta",
profileId: "p1",
});
// Spy on launchAgent.
const launchSpy = vi.spyOn(agent, "launchAgent");
renderPanel(agent);
await waitForIdle();
fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));
// The terminal container div (data-testid="terminal-view") should be mounted.
await waitFor(() => {
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
// launchAgent should have been called with the correct projectId and agentId.
await waitFor(() => {
if (launchSpy.mock.calls.length > 0) {
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
expect(launchSpy.mock.calls[0][1]).toBe(createdAgent.id);
// options (rows/cols) and onData callback
expect(typeof launchSpy.mock.calls[0][2]).toBe("object");
expect(typeof launchSpy.mock.calls[0][3]).toBe("function");
}
});
});
it("stopping an agent unmounts the terminal view", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
renderPanel(agent);
await waitForIdle();
// Launch
fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
await waitFor(() => {
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
// Stop
fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
await waitFor(() => {
expect(screen.queryByTestId("terminal-view")).toBeNull();
});
});
it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
// Seed a template so the selector has something to choose
const t = await tmpl.createTemplate({
name: "My Template",
content: "# ctx",
defaultProfileId: "p1",
});
const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate");
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
await waitForIdle();
// Select the template in the dropdown
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
fireEvent.change(templateSelect, { target: { value: t.id } });
// Fill agent name
fireEvent.change(screen.getByLabelText("agent name"), {
target: { value: "From Template" },
});
// Click Create
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
// createAgentFromTemplate should have been called with the correct args
await waitFor(() => {
expect(createFromTemplateSpy).toHaveBeenCalledWith(
PROJECT_ID,
t.id,
{ name: "From Template", synchronized: true },
);
});
// Agent should appear in the list
await screen.findByText("From Template");
});
it("shows profiles in the dropdown when profiles are configured", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "prof-1",
name: "Claude Code",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: null,
cwdTemplate: "{projectRoot}",
},
]);
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
// The select element should contain the profile option.
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.text);
expect(options).toContain("Claude Code");
});
});
// ---------------------------------------------------------------------------
// MockAgentGateway unit tests
// ---------------------------------------------------------------------------
/** Helper: minimal valid options for launchAgent. */
const LAUNCH_OPTS = { cwd: "/tmp", rows: 24, cols: 80 };
const NOOP_ON_DATA = (_bytes: Uint8Array) => {};
describe("MockAgentGateway (unit)", () => {
it("creates agents with unique slugified contextPaths", async () => {
const gw = new MockAgentGateway();
const a1 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
const a2 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
const a3 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
expect(a1.contextPath).toBe("agents/my-agent.md");
expect(a2.contextPath).toBe("agents/my-agent-2.md");
expect(a3.contextPath).toBe("agents/my-agent-3.md");
});
it("readContext throws NOT_FOUND for unknown agent", async () => {
const gw = new MockAgentGateway();
await expect(gw.readContext("proj", "ghost-id")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("deleteAgent throws NOT_FOUND for unknown agent", async () => {
const gw = new MockAgentGateway();
await expect(gw.deleteAgent("proj", "ghost-id")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("launchAgent returns a TerminalHandle with sequential session ids", async () => {
const gw = new MockAgentGateway();
const a1 = await gw.createAgent("proj", { name: "A1", profileId: "p" });
const a2 = await gw.createAgent("proj", { name: "A2", profileId: "p" });
const h1 = await gw.launchAgent("proj", a1.id, LAUNCH_OPTS, NOOP_ON_DATA);
const h2 = await gw.launchAgent("proj", a2.id, LAUNCH_OPTS, NOOP_ON_DATA);
expect(h1.sessionId).toBe("mock-agent-session-1");
expect(h2.sessionId).toBe("mock-agent-session-2");
});
it("launchAgent throws NOT_FOUND for an agent not in the project", async () => {
const gw = new MockAgentGateway();
await expect(
gw.launchAgent("proj", "unknown-id", LAUNCH_OPTS, NOOP_ON_DATA),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("launchAgent greeting is delivered via onData", async () => {
const gw = new MockAgentGateway();
const a = await gw.createAgent("proj", { name: "Greeter", profileId: "p" });
const received: Uint8Array[] = [];
await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes));
// Wait for the queueMicrotask greeting
await new Promise((r) => setTimeout(r, 0));
const dec = new TextDecoder();
const msg = received.map((b) => dec.decode(b)).join("");
expect(msg).toContain(`agent ${a.id}`);
});
it("launchAgent handle write echoes bytes back via onData", async () => {
const gw = new MockAgentGateway();
const a = await gw.createAgent("proj", { name: "Echo", profileId: "p" });
const received: Uint8Array[] = [];
const handle = await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) =>
received.push(bytes),
);
await handle.write(new TextEncoder().encode("hi"));
const dec = new TextDecoder();
const echoed = received.map((b) => dec.decode(b)).join("");
// The greeting (via queueMicrotask) and the echo both arrive; just check echo is there
expect(echoed).toContain("hi");
});
it("projects are isolated — agents in one project don't appear in another", async () => {
const gw = new MockAgentGateway();
await gw.createAgent("proj-A", { name: "A Agent", profileId: "p" });
const listB = await gw.listAgents("proj-B");
expect(listB).toHaveLength(0);
});
});