feat(model-server): frontend modèles locaux — badge de statut & CRUD serveurs (#35) et wizard multi-profils OpenCode (#36)
Sprint « Modeles locaux », couche frontend. #35 : - F35.1 badge de statut de lancement du serveur local (ModelServerLaunchBadge + useAgentsModelServer). - F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers / gateway modelServer) et ModelServerSelect. #36 : - Liste multi-profils OpenCode dans le wizard de premier lancement, gateway de clonage (clone_opencode_profile_from_seed). Tests verts (exécution réelle) : tsc propre, vitest 608/608. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,8 +13,8 @@ import {
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockProfileGateway } from "@/adapters/mock";
|
||||
import type { ProfileAvailability } from "@/domain";
|
||||
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
||||
function renderWizard(
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
onDone = vi.fn(),
|
||||
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
||||
) {
|
||||
const gateways = { profile } as unknown as Gateways;
|
||||
const gateways = { profile, modelServer } as unknown as Gateways;
|
||||
return {
|
||||
profile,
|
||||
modelServer,
|
||||
onDone,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -277,6 +279,182 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
const CLONE1 = `${OPENCODE} (copy 1)`;
|
||||
|
||||
it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Only one OpenCode row to begin with (the seed reference).
|
||||
expect(screen.getAllByText(OPENCODE).length).toBe(1);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
|
||||
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
|
||||
const added = await screen.findByLabelText(`use ${CLONE1}`);
|
||||
expect((added as HTMLInputElement).checked).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
|
||||
).toBe("http://localhost:8080/v1");
|
||||
// Its name is editable per profile (identity is the id, not the name).
|
||||
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists two distinct OpenCode profiles when both are selected", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Select the seed row and edit it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "qwen3-coder-14b" },
|
||||
});
|
||||
|
||||
// Add a second one and give it a different endpoint.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
|
||||
target: { value: "http://localhost:9191/v1" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
|
||||
expect(opencode.length).toBe(2);
|
||||
// Distinct ids (identity) and distinct endpoints.
|
||||
expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
|
||||
expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
|
||||
"http://localhost:8080/v1",
|
||||
"http://localhost:9191/v1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("Duplicate clones a row carrying its current config over", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Edit the seed row, then duplicate it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "qwen3-coder-7b" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
|
||||
);
|
||||
|
||||
const dupName = `${OPENCODE} (copy)`;
|
||||
await screen.findByLabelText(`use ${dupName}`);
|
||||
// The duplicate inherits the edited model.
|
||||
expect(
|
||||
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
|
||||
).toBe("qwen3-coder-7b");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const models = saved
|
||||
.filter((p) => p.structuredAdapter === "openCode")
|
||||
.map((p) => p.opencode?.model);
|
||||
expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("editing the name round-trips on save", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
|
||||
target: { value: "Fast local model" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.name).toBe("Fast local model");
|
||||
});
|
||||
});
|
||||
|
||||
it("reasoning/attachment round-trip on save (F36)", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
|
||||
// Reasoning defaults on (backend effective default true); turn it off.
|
||||
const reasoning = screen.getByLabelText(
|
||||
`${CLONE1} reasoning`,
|
||||
) as HTMLInputElement;
|
||||
expect(reasoning.checked).toBe(true);
|
||||
fireEvent.click(reasoning);
|
||||
// Attachments default off; turn them on.
|
||||
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.opencode?.reasoning).toBe(false);
|
||||
expect(oc?.opencode?.attachment).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
|
||||
// Seed a declared server so the dropdown offers it.
|
||||
const modelServer = new MockModelServerGateway();
|
||||
const server: LocalModelServerConfig = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
kind: "llamaCpp",
|
||||
name: "Local A",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
};
|
||||
await modelServer.saveModelServer(server);
|
||||
|
||||
const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
|
||||
// The old free-text field is gone; a dropdown replaces it.
|
||||
expect(
|
||||
screen.queryByLabelText(`${CLONE1} local model server id`),
|
||||
).toBeNull();
|
||||
const select = await screen.findByLabelText(`${CLONE1} local model server`);
|
||||
fireEvent.change(select, { target: { value: server.id } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.opencode?.localModelServerId).toBe(server.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Ticket #28 — the wizard must survive a detection that never answers. When the
|
||||
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
||||
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
||||
|
||||
Reference in New Issue
Block a user