- Implémentation reprise de session Opencode headless (conversational_recovery) - Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema - Gestion des permissions MCP pour agents externes - Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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(
|
|
<DIProvider gateways={{ profile, modelServer } as unknown as Gateways}>
|
|
<ProfilesSettings />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
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 '<name> 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<ProfileModelCatalog> {
|
|
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");
|
|
});
|
|
});
|
|
});
|