Files
IdeA/frontend/src/features/layout/LayoutGrid.test.tsx
Blomios 86f6c7ebf8 fix(web): route la famille layout dans /api/invoke + fallback UX restauration — #212
Le web-server ne routait pas load_layout (UNKNOWN_COMMAND a l'ouverture
d'un projet web PC). Alignement de l'adapter web sur les contrats Tauri:
load_layout, mutate_layout, list_layouts, create_layout, rename_layout,
delete_layout, set_active_layout. useLayout ne propage plus l'erreur
brute: message utilisateur + fallback singleLeafTree.
2026-08-15 11:40:00 +02:00

475 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* L4 — {@link LayoutGrid} wired through the real {@link DIProvider} with the
* in-memory {@link MockLayoutGateway} (+ {@link MockTerminalGateway} for the
* leaves' embedded {@link TerminalView}).
*
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
* so these tests assert the *structure* the grid renders (cells, no throw)
* rather than xterm's visual output. They stay robust whether or not xterm
* bailed.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockLayoutGateway,
MockTerminalGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
function renderGrid(layout: MockLayoutGateway, projectId = "p1") {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
</DIProvider>,
);
}
describe("LayoutGrid (with MockLayoutGateway)", () => {
it("falls back to an empty workspace with a user-facing message when layout restore fails", async () => {
const layout = new MockLayoutGateway();
vi.spyOn(layout, "loadLayout").mockRejectedValueOnce(
new Error("unknown command: load_layout"),
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
renderGrid(layout);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain(
"Impossible de restaurer l’espace de travail de ce projet.",
);
expect(alert.textContent).not.toContain("unknown command");
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
expect(warn).toHaveBeenCalledWith(
"Layout restore failed",
expect.objectContaining({ message: "unknown command: load_layout" }),
);
warn.mockRestore();
});
it("renders a single-leaf layout without throwing", async () => {
const layout = new MockLayoutGateway();
expect(() => renderGrid(layout)).not.toThrow();
// The grid container always renders.
expect(screen.getByTestId("layout-grid")).toBeTruthy();
// Once loaded, the single leaf cell is shown.
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
});
it("renders N cells for a pre-split tree", async () => {
const layout = new MockLayoutGateway();
// Pre-split the project's tree into two leaves before rendering.
const initial = await layout.loadLayout("p1");
await layout.mutateLayout("p1", {
type: "split",
target: leaves(initial)[0].id,
direction: "row",
newLeaf: "b",
container: "c",
});
renderGrid(layout);
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2);
});
// The split container is present with its direction recorded.
const split = screen.getByTestId("layout-split");
expect(split.getAttribute("data-direction")).toBe("row");
});
it("stays graceful (no throw) even though xterm bails under jsdom", async () => {
const layout = new MockLayoutGateway();
// Render a 3-leaf tree: nested splits, each leaf hosts a TerminalView.
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "split",
target: "b",
direction: "column",
newLeaf: "d",
container: "e",
});
expect(() => renderGrid(layout)).not.toThrow();
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(3);
});
});
it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => {
const layout = new MockLayoutGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
// Split A → container c with children [A (index 0), b (index 1)].
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "setSession",
target: "b",
session: "running-session-b",
});
const gateways = { layout, terminal } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
// Click the close button on cell `b` — `b` must disappear, `a` must remain.
fireEvent.click(screen.getByLabelText("close b"));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe(a);
expect(closeSpy).not.toHaveBeenCalled();
});
it("closing the other cell keeps the opposite sibling", async () => {
const layout = new MockLayoutGateway();
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
renderGrid(layout);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
// Closing `a` (index 0) must keep `b` (index 1).
fireEvent.click(screen.getByLabelText(`close ${a}`));
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe("b");
});
it("the sole root cell has no close button (cannot be closed)", async () => {
const layout = new MockLayoutGateway();
renderGrid(layout);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1),
);
expect(screen.queryByLabelText(/^close /)).toBeNull();
});
it("renders the loading/empty container when the layout gateway is absent", () => {
// A DI subset without the layout gateway → useLayout is defensive (null).
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
expect(() =>
render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/c" />
</DIProvider>,
),
).not.toThrow();
expect(screen.getByTestId("layout-grid")).toBeTruthy();
});
it("renders an explicit stopped-agent cell without showing it as idle", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
const created = await agent.createAgent("p1", {
name: "StoppedAgent",
profileId: "prof-1",
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "dead-session",
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppedAgent",
profileId: "prof-1",
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
expect(await screen.findByLabelText("StoppedAgent arrêté")).toBeTruthy();
expect(screen.getByText("Arrêté")).toBeTruthy();
expect(screen.getByText("Cette session est terminée.")).toBeTruthy();
expect(screen.getByText("Ouvre une nouvelle session pour cet agent.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Relancer" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Actualiser" })).toBeTruthy();
expect(screen.queryByText("Idle")).toBeNull();
});
it("relaunches a stopped agent from scratch in the same cell", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
const launchSpy = vi.spyOn(agent, "launchAgent");
const created = await agent.createAgent("p1", {
name: "StoppedAgent",
profileId: "prof-1",
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "dead-session",
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "old-conversation",
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppedAgent",
profileId: "prof-1",
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
expect(launchSpy.mock.calls[0]?.[0]).toBe("p1");
expect(launchSpy.mock.calls[0]?.[1]).toBe(created.id);
expect(launchSpy.mock.calls[0]?.[2]).toMatchObject({
cwd: "/home/me/proj",
nodeId: leafId,
freshStart: true,
});
expect(launchSpy.mock.calls[0]?.[2].conversationId).toBeUndefined();
await waitFor(async () => {
const fresh = await layout.loadLayout("p1");
const leaf = leaves(fresh)[0];
expect(leaf.agent).toBe(created.id);
expect(leaf.session).toMatch(/^mock-agent-session-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-/);
});
});
it("refreshes a stopped agent state without relaunching", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
const launchSpy = vi.spyOn(agent, "launchAgent");
const created = await agent.createAgent("p1", {
name: "StoppedAgent",
profileId: "prof-1",
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "dead-session",
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppedAgent",
profileId: "prof-1",
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Actualiser" }));
await waitFor(() => expect(refreshSpy).toHaveBeenCalledTimes(2));
expect(launchSpy).not.toHaveBeenCalled();
});
it("shows the transient restart label while relaunching", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
let resolveLaunch!: (value: Awaited<ReturnType<MockAgentGateway["launchAgent"]>>) => void;
const launchPromise = new Promise<Awaited<ReturnType<MockAgentGateway["launchAgent"]>>>(
(resolve) => {
resolveLaunch = resolve;
},
);
vi.spyOn(agent, "launchAgent").mockReturnValue(launchPromise);
const created = await agent.createAgent("p1", {
name: "StoppedAgent",
profileId: "prof-1",
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppedAgent",
profileId: "prof-1",
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
await waitFor(() => expect(screen.getAllByText("Relance…")).toHaveLength(2));
expect(
(screen.getByRole("button", { name: "Relance…" }) as HTMLButtonElement)
.disabled,
).toBe(true);
resolveLaunch({
sessionId: "mock-agent-session-deferred",
assignedConversationId: "mock-conversation-deferred",
write: async () => {},
resize: async () => {},
detach: () => {},
close: async () => {},
});
});
it("shows the stopped-cell restart error copy when relaunch fails", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
const created = await agent.createAgent("p1", {
name: "StoppedAgent",
profileId: "prof-1",
});
vi.spyOn(agent, "launchAgent").mockRejectedValue(
new Error("launch failed"),
);
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppedAgent",
profileId: "prof-1",
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
expect(await screen.findByText("Erreur de relance")).toBeTruthy();
expect(
screen.getByText("Impossible de relancer l’agent. Réessayez."),
).toBeTruthy();
});
});