Files
IdeA/frontend/src/features/layout/setCellAgent.test.tsx
Blomios db67ecdf8f feat(domain,application,backend,app-tauri,frontend): lot utilisateur #328 #329 #330 #331 — section plugins projet, erreurs menus plugin visibles, refresh auto des pages, droplist agents (QA verte)
Lot en un seul commit: les quatre tickets partagent les mêmes fichiers
(événements domaine/DTO, ProjectPluginsPanel, useProjectPlugins) — une
séparation par hunks aurait risqué des états intermédiaires non
compilables (convention lot des batches #310-#313, #315 et #325-#326).

#328 — section dédiée plugins dans Paramètres du projet:
- frontend: ProjectPluginsPanel intégré à ProjectSettingsPanel
  (Panneaux > Paramètres du projet) — activation/désactivation par
  plugin avec état actif/inactif explicite par projet, distinct de
  l'assignation agents/skills/tools
- domaine: événement ProjectPluginSettingsChanged publié par
  SetProjectPluginEnabled, MutateAgentPluginSkill et
  MutateAgentPluginTool après persistance de .ideai/project-plugins.json
  (application + backend + DTO camelCase, test dto.rs)

#329 — les menus plugin qui échouent ne sont plus muets:
- usePluginMenus: dispatcher strict (runCommandStrict) pour les
  commandes réellement enregistrées au lieu du dispatcher laxiste qui
  journalisait et avalait les rejets — rendait les échecs d'ouverture
  de fenêtres natifs invisibles pour l'hôte de menus
- le no-op SDK historique (commande déclarée sans handler) est préservé
- échec remonté à l'hôte via commandError/clearCommandError (journal
  console + état exposé); test window_usecases adapté à un plugin
  installé
- le correctif côté SDK/GitPlugin vit dans le projet GitPlugin (son
  commit/merge local y est fait, hors périmètre de ce dépôt)

#330 — refresh automatique des pages:
- frontend/domain: typage des événements pluginEnabled,
  pluginDisabled (restartRequired), pluginUninstalled
  (restartRequired), pluginLoadFailed et projectPluginSettingsChanged
- useProjectPlugins et PluginRuntimeProvider: souscription
  onDomainEvent — refresh sur changement catalogue global ou sur
  projectPluginSettingsChanged du projet courant; gardes
  révision/projet contre les réponses obsolètes
- useProjectAgents: refresh auto quand un agent est ajouté au projet;
  ProjectsView et AgentsPanel suivent (erreur de rafraîchissement
  affichée avec bouton Réessayer)

#331 — droplist agents des cellules:
- SmallDropdown: liste bg-overlay + bordure border-strong (plus de
  fond blanc hors thème), hover/focus bg-raised, sélection
  bg-primary/15, disabled text-faint opacity-60, role=combobox
- LayoutGrid: sélecteur d'agent des cellules migré du <select> natif
  vers SmallDropdown (mêmes libellés Terminal/visible
  ailleurs/arrière-plan)

QA verte réelle sur les quatre lots (tests ciblés + exécution).
sdk/IdeaSDK: contenu modifié non committable ici (pointeur inchangé).
Aucun push.
2026-09-12 01:14:45 +02:00

276 lines
9.0 KiB
TypeScript

/**
* #3 — Agent dropdown per cell: setCellAgent persists in the layout, and the
* terminal re-mounts with the agent opener; "Plain" reverts to a plain terminal.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import {
MockLayoutGateway,
MockAgentGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutGrid } from "./LayoutGrid";
import { leaves } from "./layout";
function renderGrid(
layout: MockLayoutGateway,
agent: MockAgentGateway,
projectId = "p1",
terminal: MockTerminalGateway = new MockTerminalGateway(),
) {
const gateways = {
layout,
terminal,
agent,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
</DIProvider>,
);
}
async function waitForAgentOption(name = "MyAgent") {
const selector = await screen.findByRole("combobox");
if (selector.getAttribute("aria-expanded") !== "true") fireEvent.click(selector);
return screen.findByRole("option", { name });
}
async function chooseAgent(name = "MyAgent") {
fireEvent.click(await waitForAgentOption(name));
}
describe("setCellAgent (agent dropdown per cell)", () => {
it("renders an agent selector on each leaf cell", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
renderGrid(layout, agent);
await waitFor(() => {
expect(screen.getAllByRole("combobox")).toHaveLength(1);
});
});
it("selecting an agent calls setCellAgent via the layout gateway", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
// Create an agent in the project.
const a = await agent.createAgent("p1", {
name: "MyAgent",
profileId: "prof-1",
});
renderGrid(layout, agent);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
await chooseAgent();
// The layout should persist the agent on the leaf.
const initial = await layout.loadLayout("p1");
const leafId = leaves(initial)[0].id;
await waitFor(async () => {
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
// The mutation should have persisted.
expect(leaf.id).toBe(leafId);
expect(leaf.agent).toBe(a.id);
// Since mutateLayout is async, give it a tick.
});
});
it("MockLayoutGateway setCellAgent persists and clears correctly", async () => {
const layout = new MockLayoutGateway();
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
// Set agent.
const after = await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: "agent-42",
});
expect(leaves(after)[0].agent).toBe("agent-42");
// Clear agent (null).
const cleared = await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: null,
});
expect(leaves(cleared)[0].agent).toBeUndefined();
});
it("selecting Plain (empty) clears the agent via mutateLayout", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", {
name: "MyAgent",
profileId: "prof-1",
});
// Pre-set agent on the leaf.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: a.id,
});
renderGrid(layout, agent);
await waitFor(() => {
const sel = screen.getByRole("combobox") as HTMLSelectElement;
// The select should show the agent (or Plain if the component loaded before the mutation).
expect(sel).toBeTruthy();
});
// Switch back to Plain.
fireEvent.click(screen.getByRole("combobox"));
fireEvent.click(await screen.findByRole("option", { name: "Terminal" }));
// After clearing, the layout should have no agent on the leaf.
await waitFor(async () => {
const fresh = await layout.loadLayout("p1");
const l = leaves(fresh)[0];
expect(l.agent === undefined || l.agent === null).toBe(true);
});
});
it("changing the agent resets the cell session to null (Bug #3)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
// Pre-set a running session on the leaf (as if a plain terminal had opened).
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "old-session",
});
renderGrid(layout, agent);
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
await chooseAgent();
await waitFor(async () => {
const fresh = await layout.loadLayout("p1");
const l = leaves(fresh)[0];
expect(l.agent).toBe(a.id);
// The stale session must be cleared so the new agent opens fresh.
expect(l.session === null || l.session === undefined).toBe(true);
});
});
it("changing the agent kills the previous PTY (Bug #3)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "old-session",
});
renderGrid(layout, agent, "p1", terminal);
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
await chooseAgent();
await waitFor(() => {
expect(closeSpy).toHaveBeenCalledWith("old-session");
});
});
it("does not kill any PTY when the cell had no session (Bug #3)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
renderGrid(layout, agent, "p1", terminal);
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
await chooseAgent();
await waitFor(async () => {
const fresh = await layout.loadLayout("p1");
expect(leaves(fresh)[0].agent).toBe(a.id);
});
expect(closeSpy).not.toHaveBeenCalled();
});
it("closing a cell kills the dropped cell's PTY (no orphan)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
// Split the root into two cells, then give the second a running session.
const tree = await layout.loadLayout("p1");
const rootId = leaves(tree)[0].id;
const afterSplit = await layout.mutateLayout("p1", {
type: "split",
target: rootId,
direction: "row",
newLeaf: "leaf-b",
container: "split-c",
});
const droppedId = leaves(afterSplit)[1].id;
await layout.mutateLayout("p1", {
type: "setSession",
target: droppedId,
session: "dropped-session",
});
renderGrid(layout, agent, "p1", terminal);
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
// now a detach-only view operation; it must not kill the PTY.
await waitFor(() => {
expect(screen.getByLabelText(`Fermer ${droppedId}`)).toBeTruthy();
});
fireEvent.click(screen.getByLabelText(`Fermer ${droppedId}`));
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
expect(closeSpy).not.toHaveBeenCalled();
});
it("agent selector shows project agents as options", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
await agent.createAgent("p1", { name: "Agent Alpha", profileId: "p" });
await agent.createAgent("p1", { name: "Agent Beta", profileId: "p" });
renderGrid(layout, agent);
fireEvent.click(await screen.findByRole("combobox"));
expect(await screen.findByRole("option", { name: "Agent Alpha" })).toBeTruthy();
expect(screen.getByRole("option", { name: "Agent Beta" })).toBeTruthy();
});
it("shows a themed empty state when the project has no agents", async () => {
renderGrid(new MockLayoutGateway(), new MockAgentGateway());
fireEvent.click(await screen.findByRole("combobox"));
const list = await screen.findByRole("listbox");
expect(list.className).toContain("bg-overlay");
expect((screen.getByRole("option", { name: "Aucun agent disponible" }) as HTMLButtonElement).disabled).toBe(true);
});
});