/** * 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, MockWindowGateway, MockWorkStateGateway } 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(), workState: new MockWorkStateGateway(), window: new MockWindowGateway(), } 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"); } /** * Opens a menu-bar dropdown item (#16 chrome). `menu` is a top-level menu * ("File"/"View"), `item` a panel entry ("Work", "Agents", "Projects…", …). * The panel then shows in its floating window. */ function openPanel(menu: string, item: string) { fireEvent.click(screen.getByRole("button", { name: menu })); fireEvent.click(screen.getByRole("button", { name: item })); } 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 it is listed in the projects manager (File → Projects…) with its root. openPanel("File", "Projects…"); expect( (await screen.findAllByText("/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 the projects manager window so the list persists across activation // (the inline welcome list is replaced by the terminal grid once active). openPanel("File", "Projects…"); // Open alpha, then beta — re-querying the list after each activation. const openInList = (name: string) => within( screen.getAllByRole("listitem").find((li) => within(li).queryByText(name))!, ).getByRole("button", { name: "Open" }); fireEvent.click(openInList("alpha")); await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(1), ); fireEvent.click(openInList("beta")); 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("shows and renders the Work sidebar tab for an active project", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); renderView(project); await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); await screen.findByRole("tab", { name: "alpha" }); openPanel("View", "Work"); expect(await screen.findByText("No agent work state.")).toBeTruthy(); }); it("switches the active project from the always-visible project selector (#16)", async () => { const project = new MockProjectGateway(); await project.createProject("alpha", "/p/a"); await project.createProject("beta", "/p/b"); renderView(project); // Open alpha from the welcome list ⇒ it becomes the active tab. await screen.findByText("/p/a"); const alphaLi = screen .getAllByRole("listitem") .find((n) => within(n).queryByText("alpha"))!; fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" })); await screen.findByRole("tab", { name: "alpha" }); // The selector reflects the active project and lists all known projects. fireEvent.click(screen.getByRole("button", { name: "Projet : alpha" })); // Pick beta from the selector — it opens/activates without the Projects window. fireEvent.click(screen.getByRole("button", { name: "beta" })); await waitFor(() => expect(screen.getByRole("button", { name: "Projet : beta" })).toBeTruthy(), ); await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2), ); const betaTab = screen .getAllByRole("tab") .find((t) => within(t).queryByText("beta"))!; expect(betaTab.getAttribute("aria-selected")).toBe("true"); }); it("toggles the AI Profiles view from the single Settings menu (#16)", async () => { renderView(); await waitForIdle(); // The project surface (create form) is visible; profiles settings are not. expect(screen.getByLabelText("project name")).toBeTruthy(); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); // Settings → AI Profiles swaps the main area to the profiles settings. openPanel("Settings", "AI Profiles"); expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); expect(screen.queryByLabelText("project name")).toBeNull(); // Settings → Close AI Profiles returns to the project surface. openPanel("Settings", "Close AI Profiles"); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); }); 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 // Reopen the projects manager (the inline form is replaced by the grid once // a project is active), then create a second project at the same root. openPanel("File", "Projects…"); await createProject("beta", "/dup/root"); const alert = await screen.findByRole("alert"); expect(alert.textContent).toMatch(/already exists/i); }); it("menu panels show the selected panel and hide others (#16)", 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 panels become active. With a project open the main area // is the terminal grid — panels now live behind the menu bar. await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(1), ); // File → Projects… opens the projects manager window — form inputs visible. openPanel("File", "Projects…"); await waitFor(() => expect(screen.getByLabelText("project name")).toBeTruthy(), ); // View → Agents — agent form visible; project form window is replaced. openPanel("View", "Agents"); await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy(), ); expect(screen.queryByLabelText("project name")).toBeNull(); // View → Context — shared project context editor visible. openPanel("View", "Context"); await waitFor(() => expect(screen.getByLabelText("project context")).toBeTruthy(), ); expect(screen.queryByLabelText("agent name")).toBeNull(); // View → Templates — the "New template" button visible. openPanel("View", "Templates"); await waitFor(() => expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(), ); expect(screen.queryByLabelText("agent name")).toBeNull(); // View → Git — commit message input visible. openPanel("View", "Git"); await waitFor(() => expect(screen.getByLabelText("commit message")).toBeTruthy(), ); // File → Projects… again — form accessible once more. openPanel("File", "Projects…"); await waitFor(() => 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" })); openPanel("View", "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(); }); });