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:
243
frontend/src/features/projects/projects.test.tsx
Normal file
243
frontend/src/features/projects/projects.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user