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:
@ -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)`;
|
||||
|
||||
Reference in New Issue
Block a user