Files
IdeA/frontend/src/features/projects/projects.test.tsx
Blomios 7efa634f23 feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)
Donne à l'utilisateur la surface pour activer le serveur depuis l'app.

- `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`.
- `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ;
  adapters mock et http/unsupported alignés (le mode web n'expose pas le
  contrôle du serveur qui l'héberge).
- `ProjectsView` : le `showSettings: boolean` devient une navigation interne
  `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles »
  disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu
  d'ajouter un `PanelId`.

CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite
de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux
champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette
loopback comme unspecified. Construit selon la spec, chaque save et chaque start
en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose
donc un select alimenté par `candidateLanAddresses` fourni par le backend :
la règle « le frontend n'invente jamais une IP » tient.

QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 ·
`npx vitest run` 87 files / 789 passed, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:43:37 +02:00

389 lines
14 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, MockDesktopServerGateway, 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.
// desktopServer is required by Settings → Deployment (#68).
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(),
desktopServer: new MockDesktopServerGateway(),
} 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 panel floating via the single « Panneaux » menu (#26): click the menu,
* click the panel entry (opens its placement submenu), then pick « Flottant ».
* `panelTitle` is the panel's human title ("Work state", "Project context",
* "Agents", "Git", "Projects", …).
*/
function openPanel(panelTitle: string) {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
}
/** Clicks an item in a flat top-level menu (e.g. Settings). */
function openMenuItem(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("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("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("Work state");
expect(await screen.findByText("No agent work state.")).toBeTruthy();
});
it("switches the active project via the tab-bar + button → Projects panel (#26)", 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 "+" button opens the Projects panel (floating); pick beta from its
// known-projects list — no verbose "Projet : …" selector anymore.
fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
const betaLi = await waitFor(() =>
screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!,
);
fireEvent.click(within(betaLi).getByRole("button", { name: "Open" }));
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("opens the Settings surface from the Settings menu and closes it from the view (#16/#68)", 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.
openMenuItem("Settings", "AI Profiles");
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("project name")).toBeNull();
// #68: closing is an explicit action in the view, not an alternating menu
// label — the label never scaled past one section.
fireEvent.click(screen.getByRole("button", { name: "Close Settings" }));
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
});
it("navigates between Settings sections from the menu and the nav column (#68)", async () => {
renderView();
await waitForIdle();
// The Settings menu now names sections; Deployment opens directly.
openMenuItem("Settings", "Deployment");
expect(await screen.findByLabelText("deployment settings")).toBeTruthy();
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// The internal nav column switches sections without leaving Settings.
const nav = screen.getByRole("navigation", { name: "settings sections" });
fireEvent.click(within(nav).getByRole("button", { name: "AI Profiles" }));
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("deployment 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("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("Projects");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
// View → Agents — agent form visible; project form window is replaced.
openPanel("Agents");
await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(),
);
expect(screen.queryByLabelText("project name")).toBeNull();
// View → Context — shared project context editor visible.
openPanel("Project context");
await waitFor(() =>
expect(screen.getByLabelText("project context")).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Templates — the "New template" button visible.
openPanel("Templates");
await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Git — commit message input visible.
openPanel("Git");
await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(),
);
// File → Projects… again — form accessible once more.
openPanel("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("Project 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();
});
});