Files
IdeaSDK/frontend/src/features/layout/LayoutGrid.modelServerOverlay.test.tsx
Blomios fe7ed0aa20 feat(model-server): afficher le téléchargement du modèle llamacpp au démarrage (#54)
Ajoute un handle du téléchargement des modèles lors du démarrage de
llamacpp : le domaine et l'application émettent la progression de
téléchargement du modèle, relayée en événement côté app-tauri, et l'UI
l'affiche via un badge de lancement et un overlay de cellule pendant que
le serveur de modèle démarre.

Backend (B1) : progression de téléchargement dans domain/application,
relais d'événement app-tauri, couverture de tests.
Frontend (F1) : modelServerLaunch, badge et overlay LayoutGrid, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:14:05 +02:00

280 lines
8.2 KiB
TypeScript

/**
* Ticket #54 F1 — model-server download overlay on agent cells.
*
* When a pinned agent's OpenCode profile binds a local model server and that
* server emits `downloading` / `starting` / `probing` (via the existing
* `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so
* the boot no longer looks like a hung/timed-out empty terminal. The overlay
* retracts at `ready`/`failed`.
*
* Correlation is `agentId → profileId → opencode.localModelServerId → serverId`:
* two cells whose agents share the same bound server are BOTH veiled by a single
* event on that server. xterm is stubbed so the terminal mounts under jsdom.
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { AgentProfile, ModelServerStatus } from "@/domain";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockInputGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
const SERVER_ID = "srv-local-1";
/** A profile that binds the given local model server through OpenCode. */
function localModelProfile(id: string): AgentProfile {
return {
id,
name: "OpenCode + llama.cpp",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencode: {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
localModelServerId: SERVER_ID,
},
};
}
function makeGateways(
agentGateway: MockAgentGateway,
profileGateway: MockProfileGateway,
systemGateway: MockSystemGateway,
): Gateways {
return {
layout: new MockLayoutGateway(),
agent: agentGateway,
profile: profileGateway,
terminal: new MockTerminalGateway(),
system: systemGateway,
input: new MockInputGateway(),
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
async function emit(
systemGateway: MockSystemGateway,
status: ModelServerStatus,
): Promise<void> {
await act(async () => {
systemGateway.emit({
type: "modelServerStatusChanged",
serverId: SERVER_ID,
status,
});
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
it("veils the cell while downloading, then retracts at ready", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
// Pin the agent onto the single default cell.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// No overlay before any status.
expect(screen.queryByTestId("model-server-overlay")).toBeNull();
// Downloading → overlay with the download wording.
await emit(systemGateway, {
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
});
await waitFor(() =>
expect(screen.getByTestId("model-server-overlay")).toBeTruthy(),
);
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
"Téléchargement du modèle",
);
// Ready → overlay gone.
await emit(systemGateway, { state: "ready", reused: false });
await waitFor(() =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await emit(systemGateway, { state: "probing" });
await waitFor(() =>
expect(
screen.getByTestId("model-server-overlay").textContent,
).toContain("Chargement du serveur"),
);
await emit(systemGateway, { state: "starting" });
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
"Chargement du serveur",
);
await emit(systemGateway, {
state: "failed",
code: "spawn",
message: "boom",
});
await waitFor(() =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
it("veils EVERY cell whose agent references the downloading server", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agentA = await agentGateway.createAgent("p1", {
name: "A",
profileId: "opencode-local",
});
const agentB = await agentGateway.createAgent("p1", {
name: "B",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
// Split the root into two cells, pin one agent onto each.
const layout = gateways.layout as MockLayoutGateway;
const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: rootLeaf,
direction: "row",
newLeaf: "cell-b",
container: "split-c",
});
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: rootLeaf,
agent: agentA.id,
});
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: "cell-b",
agent: agentB.id,
});
renderGrid(gateways);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf").length).toBe(2),
);
await emit(systemGateway, {
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
});
// Both cells carry the overlay from a single server event.
await waitFor(() =>
expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
);
await emit(systemGateway, { state: "ready", reused: true });
await waitFor(() =>
expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
);
});
});