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,243 @@
/**
* L2 — the projects feature wired to the stateful `MockProjectGateway` via the
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
*/
import { describe, it, expect } from "vitest";
import {
render,
screen,
within,
waitFor,
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
/** Renders the view behind a DI provider seeded with a fresh project gateway. */
function renderView(
project: MockProjectGateway = new MockProjectGateway(),
system: MockSystemGateway = new MockSystemGateway(),
) {
// The project gateway drives the primary assertions; agent + profile stubs are
// required because ProjectsView now renders AgentsPanel for the active tab.
// template gateway is required because ProjectsView now renders TemplatesPanel.
const agentGateway = new MockAgentGateway();
const gateways = {
system,
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
} as unknown as Gateways;
return {
project,
system,
...render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
),
};
}
/** Waits for the initial async refresh to settle (Refresh button re-enabled). */
async function waitForIdle() {
await waitFor(() =>
expect(
(
screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement
).disabled,
).toBe(false),
);
}
/** Fills the create form and submits it (assumes the form is idle/enabled). */
async function createProject(name: string, root: string) {
await waitForIdle();
fireEvent.change(screen.getByLabelText("project name"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("project root"), {
target: { value: root },
});
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
}
function tablist() {
return screen.getByRole("tablist");
}
describe("ProjectsView (with MockProjectGateway)", () => {
it("creating a project adds it to the known list and opens a tab", async () => {
renderView();
expect(screen.getByText("No projects yet.")).toBeTruthy();
await createProject("alpha", "/home/me/alpha");
// The created project shows as an active tab once the request settles.
const tab = await screen.findByRole("tab");
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(within(tab).getByText("alpha")).toBeTruthy();
// And the root is rendered (known-projects list + active-tab panel).
expect(screen.getAllByText("/home/me/alpha").length).toBeGreaterThan(0);
});
it("opening a project creates a tab and makes it the active one", async () => {
// Seed two projects directly through the stateful gateway.
const project = new MockProjectGateway();
const a = await project.createProject("alpha", "/p/a");
const b = await project.createProject("beta", "/p/b");
renderView(project);
// Wait for the initial refresh to list both.
await screen.findByText("/p/a");
await screen.findByText("/p/b");
// Open alpha, then beta.
const items = screen.getAllByRole("listitem");
const openA = within(
items.find((li) => within(li).queryByText("alpha"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openA);
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
const openB = within(
items.find((li) => within(li).queryByText("beta"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openB);
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
);
// The last-opened tab (beta) is active.
const tabs = within(tablist()).getAllByRole("tab");
const betaTab = tabs.find((t) => within(t).queryByText("beta"))!;
const alphaTab = tabs.find((t) => within(t).queryByText("alpha"))!;
expect(betaTab.getAttribute("aria-selected")).toBe("true");
expect(alphaTab.getAttribute("aria-selected")).toBe("false");
// Ignore unused binding lint for `a`/`b`.
expect([a.id, b.id]).toHaveLength(2);
});
it("closing a tab removes it from the tab bar", async () => {
renderView();
await createProject("alpha", "/home/me/alpha");
await screen.findByRole("tab");
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
await waitFor(() => {
expect(screen.getByText("No open tabs.")).toBeTruthy();
});
});
it("creating a project with a duplicate root surfaces an INVALID error", async () => {
renderView();
await createProject("alpha", "/dup/root");
await screen.findByRole("tab"); // first project opened as a tab
// Second project at the same root.
await createProject("beta", "/dup/root");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/already exists/i);
});
it("sidebar tab switch shows the selected panel and hides others", async () => {
// Set up a project so that agent/template/git panels are reachable.
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
renderView(project);
// Open alpha so the sidebar panels become active.
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
// Default sidebar tab is "Projects" — the form inputs must be accessible.
expect(screen.getByLabelText("project name")).toBeTruthy();
expect(screen.getByLabelText("project root")).toBeTruthy();
// Switch to Agents tab — agent form should be visible.
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
// Agent panel renders an "agent name" input; project form is now hidden.
await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(),
);
expect(screen.queryByLabelText("project name")).toBeNull();
// Switch to Templates tab — the "New template" button should be visible.
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// Switch to Git tab — commit message input should be visible.
fireEvent.click(screen.getByRole("button", { name: "Git" }));
await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(),
);
// Switch back to Projects tab — form is accessible again.
fireEvent.click(screen.getByRole("button", { name: "Projects" }));
expect(screen.getByLabelText("project name")).toBeTruthy();
});
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
const system = new MockSystemGateway();
renderView(new MockProjectGateway(), system);
await waitForIdle();
// Root field starts empty.
const rootInput = screen.getByLabelText("project root") as HTMLInputElement;
expect(rootInput.value).toBe("");
// Click Browse… — the mock returns "/home/user/mock-project".
fireEvent.click(screen.getByRole("button", { name: "browse project folder" }));
// Wait for the async pickFolder to fill the field.
await waitFor(() => {
expect((screen.getByLabelText("project root") as HTMLInputElement).value).toBe(
"/home/user/mock-project",
);
});
});
it("opening an unknown project id surfaces a NOT_FOUND error", async () => {
// Stub a stale list entry whose id the gateway won't resolve.
const project = new MockProjectGateway();
project.listProjects = async () => [
{
id: "ghost-id",
name: "ghost",
root: "/p/ghost",
remote: { kind: "local" },
createdAt: 0,
},
];
renderView(project);
await screen.findByText("/p/ghost");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/not found/i);
expect(screen.getByText("No open tabs.")).toBeTruthy();
});
});