Détache les vues dans de vraies fenêtres système Tauri (multi-écran, fullscreen). Backend : commandes de gestion de WebviewWindow, capabilities et composition root. Frontend : nouveau port WindowGateway et son adaptateur window, entrée panel-only ViewWindow/ViewPanelBody, détachement câblé dans ProjectsView. QA vert : app-tauri 63, frontend typecheck + vitest 546/546. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
/**
|
|
* 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(
|
|
<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");
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
});
|