feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)

Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).

- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)

Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:34:50 +02:00
parent aab4bcafb6
commit d89380cdf0
9 changed files with 656 additions and 10 deletions

View File

@ -173,6 +173,134 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
});
});
describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => {
const OLLAMA = "Ollama / OpenAI-compatible local model";
it("offers the local model as a selectable, pre-filled HTTP config row", async () => {
renderWizard();
await waitForLoaded();
expect(screen.getByText(OLLAMA)).toBeTruthy();
// The structured HTTP fields are rendered, pre-filled from the reference.
expect(
(screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value,
).toBe("http://localhost:11434/v1");
expect(
(screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value,
).toBe("qwen2.5-coder");
// Claude/Codex rows must NOT grow HTTP fields (additive, no regression).
expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull();
});
it("labels the API key field as an env var NAME and flags a raw key", async () => {
renderWizard();
await waitForLoaded();
const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement;
// The label/placeholder make the env-var-name intent explicit.
expect(apiKey.placeholder.toLowerCase()).toContain("variable name");
// A raw secret is not a valid env var identifier ⇒ inline error.
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
expect(
screen.getByText(/valid env var name \(not the key itself\)/i),
).toBeTruthy();
// A proper env var name clears the error.
fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } });
expect(
screen.queryByText(/valid env var name \(not the key itself\)/i),
).toBeNull();
});
it("flags an invalid endpoint scheme inline", async () => {
renderWizard();
await waitForLoaded();
const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`);
fireEvent.change(endpoint, { target: { value: "ftp://oops" } });
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
});
it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => {
renderWizard();
await waitForLoaded();
expect(
(screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value,
).toBe("120000");
expect(
(screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value,
).toBe("5000");
expect(
(screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement)
.value,
).toBe("16");
});
it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
target: { value: "90000" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), {
target: { value: "3000" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), {
target: { value: "8" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const ollama = saved.find((p) => p.command === "openai-compatible");
expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000);
expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000);
expect(ollama?.chatHttp?.maxToolIterations).toBe(8);
});
});
it("flags a zero timeout inline (mirror of the backend guard)", async () => {
renderWizard();
await waitForLoaded();
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
target: { value: "0" },
});
expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy();
});
it("persists the edited HTTP config round-trip when selected and saved", async () => {
const { profile } = renderWizard();
await waitForLoaded();
// Select the local model and edit its model name.
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), {
target: { value: "qwen3" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), {
target: { value: "LAN_KEY" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const ollama = saved.find((p) => p.command === "openai-compatible");
expect(ollama?.structuredAdapter).toBe("openAiCompatible");
expect(ollama?.chatHttp?.model).toBe("qwen3");
expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY");
// The endpoint round-trips untouched.
expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1");
});
});
});
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
/** A gateway whose first run is already done (profiles configured). */
async function configuredGateway() {