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"); }); }); });