import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { LocalModelServerConfig, ProfileModelCatalog } from "@/domain";
import { ProfilesSettings } from "./ProfilesSettings";
function renderSettings(
profile: MockProfileGateway = new MockProfileGateway(),
modelServer: MockModelServerGateway = new MockModelServerGateway(),
) {
return {
profile,
modelServer,
...render(
,
),
};
}
async function waitReady() {
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
}
async function createProfile() {
const before = screen.queryAllByRole("listitem").length;
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
}
describe("ProfilesSettings", () => {
it("creates multiple named Codex and Claude profiles with different models", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
await createProfile();
let rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Codex mini" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "gpt-5-mini" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
fireEvent.click(screen.getByRole("tab", { name: "Claude" }));
await waitReady();
await createProfile();
await createProfile();
rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Claude Opus" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "claude-opus-4-8" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2);
expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2);
expect(saved.map((p) => p.model)).toEqual(
expect.arrayContaining([
"gpt-5-codex",
"gpt-5-mini",
"claude-sonnet-5",
"claude-opus-4-8",
]),
);
});
});
it("duplicates from an existing profile with ' copy' and preserves the model", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
});
});
it("keeps manual model entry available when the catalogue fails", async () => {
class CatalogueDownProfileGateway extends MockProfileGateway {
listCodexModels(): Promise {
return Promise.reject(new Error("catalogue down"));
}
}
renderSettings(new CatalogueDownProfileGateway());
await waitReady();
expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy();
await createProfile();
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
/modele du profil/,
) as HTMLInputElement;
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(model.value).toBe("future-codex-model");
expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0);
});
it("shows compatibility states in suggestions and contextual help", async () => {
renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.focus(model);
expect(await within(row).findByText("Compatible")).toBeTruthy();
expect(
within(row).getByText(
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
),
).toBeTruthy();
fireEvent.change(model, { target: { value: "" } });
expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0);
expect(within(row).getByText("Probablement trop récent")).toBeTruthy();
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(within(row).getByText("Inconnu")).toBeTruthy();
expect(
within(row).getByText(
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
),
).toBeTruthy();
});
it("saves likely-too-recent models and shows a non-blocking warning", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true);
});
expect(
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
).toBeTruthy();
});
it("restores OpenCode local servers, local binding, and cloud provider choice in settings", async () => {
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",
host: "127.0.0.1",
jinja: false,
args: [],
autoStart: false,
stopPolicy: "stopOnAppExit",
};
await modelServer.saveModelServer(server);
const { profile } = renderSettings(new MockProfileGateway(), modelServer);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
expect(screen.getByRole("button", { name: "add model server" })).toBeTruthy();
expect(screen.getByText("Local A")).toBeTruthy();
await createProfile();
const select = await screen.findByLabelText(
"OpenCode + llama.cpp copy local model server",
);
fireEvent.change(select, { target: { value: server.id } });
const profileRow = select.closest("li");
expect(profileRow).not.toBeNull();
fireEvent.click(
within(profileRow as HTMLElement).getByRole("button", {
name: "Enregistrer",
}),
);
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.find((p) => p.structuredAdapter === "openCode");
expect(opencode?.opencode?.localModelServerId).toBe(server.id);
});
fireEvent.click(
within(profileRow as HTMLElement).getByRole("radio", {
name: "Provider cloud",
}),
);
expect(
await within(profileRow as HTMLElement).findByLabelText(
"OpenCode + llama.cpp copy provider",
),
).toBeTruthy();
});
it("creates OpenCode profiles through the OpenCode clone flow and persists them for agent pickers", async () => {
const profile = new MockProfileGateway();
const cloneOpenCodeSpy = vi.spyOn(profile, "cloneOpenCodeProfileFromSeed");
const cloneGenericSpy = vi.spyOn(profile, "cloneProfileFromSeed");
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy");
expect(name).toBeTruthy();
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy");
expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true);
});
expect(cloneOpenCodeSpy).toHaveBeenCalledWith({
name: "OpenCode + llama.cpp copy",
opencode: expect.objectContaining({
baseURL: "http://localhost:8080/v1",
model: "qwen3-coder-30b",
}),
});
expect(cloneGenericSpy).not.toHaveBeenCalled();
});
it("duplicates OpenCode profiles through the OpenCode clone flow and preserves their local config", async () => {
const profile = new MockProfileGateway();
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), {
target: { value: "local-special" },
});
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved[0]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
});
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", {
name: "Dupliquer",
}));
await waitFor(async () => {
const saved = await profile.listProfiles();
const copies = saved.filter((p) => p.structuredAdapter === "openCode");
expect(copies).toHaveLength(2);
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy");
expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
expect(copies[1]?.opencode?.model).toBe("local-special");
});
});
});