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.
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|