/** * 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( , ), }; } /** 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 Context tab — shared project context editor should be visible. fireEvent.click(screen.getByRole("button", { name: "Context" })); await waitFor(() => expect(screen.getByLabelText("project context")).toBeTruthy(), ); expect(screen.queryByLabelText("agent 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("edits the shared project context stored under .ideai", async () => { const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); await project.updateProjectContext(created.id, "# Existing context"); renderView(project); await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); fireEvent.click(screen.getByRole("button", { name: "Context" })); const editor = (await screen.findByLabelText( "project context", )) as HTMLTextAreaElement; expect(editor.value).toBe("# Existing context"); fireEvent.change(editor, { target: { value: "# Shared\n\nUse pnpm." } }); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(async () => { await expect(project.readProjectContext(created.id)).resolves.toBe( "# Shared\n\nUse pnpm.", ); }); }); 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(); }); });