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

@ -10,7 +10,7 @@
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
*/
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type {
@ -205,3 +205,88 @@ describe("TerminalView (with MockTerminalGateway)", () => {
expect(true).toBe(true);
});
});
// The launch-error surface (ticket #14 F3) can only be observed when xterm
// actually mounts — otherwise the effect bails before ever calling the opener.
// jsdom lacks `matchMedia`/`ResizeObserver`, which is exactly what makes
// `term.open` throw. This block installs minimal polyfills so xterm mounts and
// the opener genuinely runs (and rejects), letting us assert the real rendered
// error. Scoped + torn down so the rest of the file keeps its headless
// bail-graceful contract untouched.
describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () => {
const w = window as unknown as {
matchMedia?: (q: string) => MediaQueryList;
};
const savedMatchMedia = w.matchMedia;
const savedResizeObserver = globalThis.ResizeObserver;
beforeAll(() => {
w.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterAll(() => {
w.matchMedia = savedMatchMedia;
globalThis.ResizeObserver = savedResizeObserver;
});
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
// Guards the premise of the tests below: if this fails, the opener never
// fired and the error assertions would be vacuous.
const open = vi.fn(async () => makeHandle({ sessionId: "up-1" }));
renderView(new MockTerminalGateway(), "/cwd", { open });
await waitFor(() => expect(open).toHaveBeenCalled());
});
it("renders an 'endpoint unavailable' launch failure as a visible alert without blocking the UI", async () => {
// An OpenAI-compatible agent whose endpoint is down rejects the launch with a
// Start error. The cell must show a visible, accessible error message — never
// throw, never crash — so IdeA stays usable and other cells keep working.
const open = vi.fn(async () => {
throw {
code: "AGENT_SESSION_START",
message: "endpoint indisponible: http://localhost:11434/v1",
};
});
expect(() =>
renderView(new MockTerminalGateway(), "/cwd", { open }),
).not.toThrow();
// The rejection is actually exercised (the opener was invoked and threw).
await waitFor(() => expect(open).toHaveBeenCalled());
// A real, accessible error message is rendered to the user (role="alert"),
// carrying the endpoint diagnostic — not just painted into the xterm buffer.
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/Échec du lancement de l'agent/);
expect(alert.textContent).toMatch(/endpoint indisponible/);
// The cell stays mounted and interactive (not a blank/blocked screen).
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("shows NO failure banner on a successful launch", async () => {
// Guard: the alert is strictly a failure surface — a healthy cell has none.
const open = vi.fn(async () => makeHandle({ sessionId: "ok-1" }));
renderView(new MockTerminalGateway(), "/cwd", { open });
await waitFor(() => expect(open).toHaveBeenCalled());
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByTestId("terminal-error")).toBeNull();
});
});