feat(frontend): support des providers OpenCode cloud (#92)

Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard
first-run (sélection, édition, sauvegarde, suppression), le domaine et
les ports associés, ainsi que les adapters HTTP/mock correspondants.

Build + 952 tests verts, comportements clés vérifiés en exécution réelle,
y compris un test de couverture ajouté pour le parcours d'édition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:18:02 +02:00
parent 23a3c2788f
commit c181b43d04
8 changed files with 649 additions and 6 deletions

View File

@ -11,6 +11,7 @@ import {
screen,
waitFor,
fireEvent,
within,
} from "@testing-library/react";
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
@ -283,6 +284,152 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
});
});
describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
const OPENCODE = "OpenCode + llama.cpp";
it("defaults to Local, and switching to Cloud swaps the sub-form", async () => {
renderWizard();
await waitForLoaded();
expect(
screen.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
"aria-checked",
),
).toBe("true");
expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
await screen.findByLabelText(`${OPENCODE} provider`);
});
it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "cfg-oc-cloud-1",
name: "Claude via OpenCode",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "opencode --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencodeProvider: {
providerId: "anthropic",
model: "claude-sonnet-5",
apiKeyRef: "secret-ref-1",
},
},
]);
const gateways = {
profile,
modelServer: new MockModelServerGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<FirstRunWizard forceOpen />
</DIProvider>,
);
await waitForLoaded();
// Scope to this row: the still-unconfigured "OpenCode + llama.cpp"
// reference also renders (edit mode dedups by id only), with its own
// Local/Cloud segmented control.
const row = within(
screen.getByLabelText("use Claude via OpenCode").closest("li")!,
);
expect(
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
"aria-checked",
),
).toBe("true");
expect(
row.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
"aria-checked",
),
).toBe("false");
expect(
(row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
.value,
).toBe("");
});
it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement;
expect(modelSelect.disabled).toBe(true);
fireEvent.change(providerSelect, { target: { value: "anthropic" } });
expect(modelSelect.disabled).toBe(false);
const saveButton = screen.getByRole("button", {
name: "Enregistrer",
}) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } });
expect(saveButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "sk-ant-secret" },
});
expect(saveButton.disabled).toBe(false);
});
it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), {
target: { value: "anthropic" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "claude-sonnet-5" },
});
const apiKeyInput = screen.getByLabelText(
`${OPENCODE} api key`,
) as HTMLInputElement;
fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.find((p) => p.command === "opencode");
expect(opencode?.opencodeProvider?.providerId).toBe("anthropic");
expect(opencode?.opencodeProvider?.model).toBe("claude-sonnet-5");
expect(opencode?.opencode).toBeUndefined();
});
// The key is never kept around client-side once saved.
expect(apiKeyInput.value).toBe("");
});
it("shows submit-time validation messages when provider/model/key are missing", async () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
await screen.findByLabelText(`${OPENCODE} provider`);
// Save stays disabled with no key, so drive validation via a filled key but
// no provider/model to see the field-level messages fire on submit.
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "sk-ant-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
});
});
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
const OPENCODE = "OpenCode + llama.cpp";
const CLONE1 = `${OPENCODE} (copy 1)`;