Le champ contrôlé de ModelServersPanel/FirstRunWizard perdait les espaces saisis dans les arguments llama.cpp (trim/split prématuré sur chaque frappe au lieu de la seule sérialisation finale) (#113). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1224 lines
44 KiB
TypeScript
1224 lines
44 KiB
TypeScript
/**
|
|
* L5 — the first-run wizard wired to the stateful {@link MockProfileGateway} via
|
|
* the real {@link DIProvider}. Covers: pre-filled editable rows (only the
|
|
* selectable Claude/Codex profiles, §17.3/D7), detection ✓/✗, the **absence** of
|
|
* any custom-profile block, and finishing (configure ⇒ first run closed ⇒ onDone).
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
act,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
fireEvent,
|
|
within,
|
|
} from "@testing-library/react";
|
|
|
|
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
|
import type {
|
|
AgentProfile,
|
|
LocalModelServerConfig,
|
|
ProfileAvailability,
|
|
} from "@/domain";
|
|
import type { Gateways } from "@/ports";
|
|
import { DIProvider } from "@/app/di";
|
|
import { FirstRunWizard } from "./FirstRunWizard";
|
|
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
|
|
|
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
|
fireEvent.click(screen.getByLabelText(label));
|
|
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
|
}
|
|
|
|
function renderWizard(
|
|
profile: MockProfileGateway = new MockProfileGateway(),
|
|
onDone = vi.fn(),
|
|
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
|
) {
|
|
const gateways = { profile, modelServer } as unknown as Gateways;
|
|
return {
|
|
profile,
|
|
modelServer,
|
|
onDone,
|
|
...render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard onDone={onDone} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function waitForLoaded() {
|
|
await screen.findByLabelText("first run setup");
|
|
}
|
|
|
|
describe("FirstRunWizard (with MockProfileGateway)", () => {
|
|
it("renders only the selectable (Claude/Codex) pre-filled, editable profiles", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Only structured-drivable profiles are offered (§17.3/D7).
|
|
for (const name of ["Claude Code", "OpenAI Codex CLI"]) {
|
|
expect(screen.getByText(name)).toBeTruthy();
|
|
}
|
|
// Gemini/Aider are no longer proposed for selection.
|
|
expect(screen.queryByText("Gemini CLI")).toBeNull();
|
|
expect(screen.queryByText("Aider")).toBeNull();
|
|
expect(screen.queryByLabelText("Aider command")).toBeNull();
|
|
|
|
// Commands are editable inputs, pre-filled.
|
|
const claudeCmd = screen.getByLabelText(
|
|
"Claude Code command",
|
|
) as HTMLInputElement;
|
|
expect(claudeCmd.value).toBe("claude");
|
|
});
|
|
|
|
it("editing a command updates the input value", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
const cmd = screen.getByLabelText(
|
|
"OpenAI Codex CLI command",
|
|
) as HTMLInputElement;
|
|
fireEvent.change(cmd, { target: { value: "codex-2" } });
|
|
expect(cmd.value).toBe("codex-2");
|
|
});
|
|
|
|
it("keeps args spaces while typing and parses them on blur", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
await waitFor(() =>
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(true),
|
|
);
|
|
|
|
const args = screen.getByLabelText("Claude Code args") as HTMLInputElement;
|
|
fireEvent.change(args, {
|
|
target: { value: "--model claude-sonnet-4-5 --verbose " },
|
|
});
|
|
expect(args.value).toBe("--model claude-sonnet-4-5 --verbose ");
|
|
|
|
fireEvent.blur(args);
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const [saved] = await profile.listProfiles();
|
|
expect(saved.args).toEqual(["--model", "claude-sonnet-4-5", "--verbose"]);
|
|
});
|
|
});
|
|
|
|
it("detection shows ✓ for claude and ✗ for the rest", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByLabelText("Claude Code availability").textContent,
|
|
).toMatch(/installed/);
|
|
});
|
|
expect(
|
|
screen.getByLabelText("OpenAI Codex CLI availability").textContent,
|
|
).toMatch(/not found/);
|
|
});
|
|
|
|
// §17.3/D7 — anti-regression guard. The custom-profile block was removed
|
|
// because we cannot drive an arbitrary command in structured mode. This test
|
|
// asserts the *absence* of that block: it must fail the instant any
|
|
// `AddCustomProfile` UI (its label/role/inputs) reappears.
|
|
it("offers no custom-profile block (removed in D7)", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
expect(screen.queryByLabelText("add custom profile")).toBeNull();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Add custom profile" }),
|
|
).toBeNull();
|
|
expect(screen.queryByLabelText("custom name")).toBeNull();
|
|
expect(screen.queryByLabelText("custom command")).toBeNull();
|
|
});
|
|
|
|
it("auto-detects on open and pre-checks only installed CLIs", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// The mock reports only `claude` as installed → only it is pre-checked.
|
|
await waitFor(() =>
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(true),
|
|
);
|
|
expect(
|
|
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
|
|
).toBe(false);
|
|
// Availability was filled automatically, without clicking the button.
|
|
expect(
|
|
screen.getByLabelText("Claude Code availability").textContent,
|
|
).toMatch(/installed/);
|
|
});
|
|
|
|
it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
|
|
const { profile, onDone } = renderWizard();
|
|
await waitForLoaded();
|
|
await waitFor(() =>
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(true),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
|
await waitFor(() =>
|
|
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
|
);
|
|
|
|
// Only the installed CLI was pre-selected, so only it is persisted.
|
|
const saved = await profile.listProfiles();
|
|
expect(saved.map((p) => p.command)).toEqual(["claude"]);
|
|
expect((await profile.firstRunState()).isFirstRun).toBe(false);
|
|
});
|
|
|
|
it("ticking an extra profile persists it alongside the installed ones", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
await waitFor(() =>
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(true),
|
|
);
|
|
|
|
// Keep Codex too (not installed, unchecked by default).
|
|
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
expect(saved.map((p) => p.command)).toEqual(["claude", "codex"]);
|
|
});
|
|
});
|
|
|
|
it("renders nothing when it is not the first run", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([]); // closes first run
|
|
const { container } = renderWizard(profile);
|
|
|
|
// No wizard section ever appears.
|
|
await waitFor(() =>
|
|
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
|
);
|
|
expect(container.querySelector("section")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", () => {
|
|
it("shows only a free-form model field for Codex and persists no provider/API key", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
await waitFor(() =>
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(true),
|
|
);
|
|
|
|
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
|
|
|
const row = within(
|
|
screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
|
|
);
|
|
expect(row.queryByLabelText("OpenAI Codex CLI provider")).toBeNull();
|
|
expect(row.queryByLabelText("OpenAI Codex CLI provider search")).toBeNull();
|
|
expect(row.queryByLabelText("OpenAI Codex CLI api key")).toBeNull();
|
|
|
|
fireEvent.change(row.getByLabelText("OpenAI Codex CLI model"), {
|
|
target: { value: "gpt-5-codex" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const codex = saved.find((p) => p.command === "codex");
|
|
expect(codex?.model).toBe("gpt-5-codex");
|
|
expect(JSON.stringify(codex)).not.toContain("provider");
|
|
expect(JSON.stringify(codex)).not.toContain("apiKey");
|
|
});
|
|
});
|
|
|
|
it("shows only a free-form model field for Claude and persists no provider/API key", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
const claudeToggle = screen.getByLabelText(
|
|
"use Claude Code",
|
|
) as HTMLInputElement;
|
|
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
|
|
|
|
const row = within(claudeToggle.closest("li")!);
|
|
expect(row.queryByLabelText("Claude Code provider")).toBeNull();
|
|
expect(row.queryByLabelText("Claude Code provider search")).toBeNull();
|
|
expect(row.queryByLabelText("Claude Code api key")).toBeNull();
|
|
|
|
fireEvent.change(row.getByLabelText("Claude Code model"), {
|
|
target: { value: "claude-sonnet-4-5" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const claude = saved.find((p) => p.command === "claude");
|
|
expect(claude?.model).toBe("claude-sonnet-4-5");
|
|
expect(JSON.stringify(claude)).not.toContain("provider");
|
|
expect(JSON.stringify(claude)).not.toContain("apiKey");
|
|
});
|
|
});
|
|
|
|
it("edits an existing Codex model without rendering provider/API key fields", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
{
|
|
id: "codex-existing",
|
|
name: "Codex configured",
|
|
command: "codex",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
|
detect: "codex --version",
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "codex",
|
|
model: "gpt-5-mini",
|
|
},
|
|
]);
|
|
const gateways = {
|
|
profile,
|
|
modelServer: new MockModelServerGateway(),
|
|
} as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard forceOpen />
|
|
</DIProvider>,
|
|
);
|
|
await waitForLoaded();
|
|
|
|
const row = within(
|
|
screen.getByLabelText("use Codex configured").closest("li")!,
|
|
);
|
|
expect(row.queryByLabelText("Codex configured provider")).toBeNull();
|
|
expect(row.queryByLabelText("Codex configured api key")).toBeNull();
|
|
expect(
|
|
(row.getByLabelText("Codex configured model") as HTMLInputElement).value,
|
|
).toBe("gpt-5-mini");
|
|
|
|
fireEvent.change(row.getByLabelText("Codex configured model"), {
|
|
target: { value: "gpt-5" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const [saved] = await profile.listProfiles();
|
|
expect(saved.model).toBe("gpt-5");
|
|
expect(JSON.stringify(saved)).not.toContain("provider");
|
|
expect(JSON.stringify(saved)).not.toContain("apiKey");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
|
const OPENCODE = "OpenCode + llama.cpp";
|
|
|
|
it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
expect(screen.getByText(OPENCODE)).toBeTruthy();
|
|
// The OpenCode fields are rendered, pre-filled from the reference.
|
|
expect(
|
|
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
|
|
).toBe("http://localhost:8080/v1");
|
|
expect(
|
|
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
|
|
).toBe("qwen3-coder-30b");
|
|
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
|
|
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
|
|
});
|
|
|
|
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
|
|
expect(apiKey.value).toBe("sk-no-key");
|
|
|
|
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
|
|
// env-var-name error appears.
|
|
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
|
expect(apiKey.value).toBe("sk-secret-123");
|
|
expect(
|
|
screen.queryByText(/valid env var name/i),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("flags an invalid base URL scheme inline", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
|
|
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
|
|
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
|
});
|
|
|
|
it("flags an empty model inline", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "" },
|
|
});
|
|
expect(screen.getByText(/model is required/i)).toBeTruthy();
|
|
});
|
|
|
|
it("persists the edited OpenCode config round-trip when selected and saved", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Select the local model and edit its base URL, model and API key.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
|
|
target: { value: "http://localhost:9090/v1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-14b" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "sk-local" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.find((p) => p.command === "opencode");
|
|
expect(opencode?.structuredAdapter).toBe("openCode");
|
|
expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
|
expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
|
|
expect(opencode?.opencode?.apiKey).toBe("sk-local");
|
|
});
|
|
});
|
|
|
|
it("clearing the API key drops it (optional, omitted when blank)", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.find((p) => p.command === "opencode");
|
|
expect(opencode?.opencode?.apiKey).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
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" }));
|
|
|
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
|
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
|
|
expect(modelSelect.disabled).toBe(true);
|
|
|
|
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
|
expect(modelSelect.disabled).toBe(false);
|
|
|
|
const saveButton = screen.getByRole("button", {
|
|
name: "Enregistrer",
|
|
}) as HTMLButtonElement;
|
|
expect(saveButton.disabled).toBe(true);
|
|
|
|
chooseDropdownOption(`${OPENCODE} model`, "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" }));
|
|
|
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
|
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
|
chooseDropdownOption(`${OPENCODE} model`, "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 — OpenCode custom cloud provider (ticket #92, dynamic catalogue)", () => {
|
|
const OPENCODE = "OpenCode + llama.cpp";
|
|
|
|
async function goToCloudCustomMode() {
|
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
|
chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
|
|
}
|
|
|
|
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
await goToCloudCustomMode();
|
|
|
|
expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull();
|
|
expect(
|
|
screen.getByLabelText(`${OPENCODE} custom provider id`),
|
|
).toBeTruthy();
|
|
expect(
|
|
(screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement)
|
|
.value,
|
|
).toBe("@ai-sdk/openai-compatible");
|
|
expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy();
|
|
expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy();
|
|
expect(
|
|
screen.getByLabelText(`${OPENCODE} custom display name`),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("validates the required custom fields on submit", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
await goToCloudCustomMode();
|
|
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "sk-custom-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();
|
|
expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy();
|
|
// npm is pre-filled by default, so it doesn't fail validation here.
|
|
expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull();
|
|
});
|
|
|
|
it("saves a custom provider profile with the custom config mapped through", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
await goToCloudCustomMode();
|
|
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
|
|
target: { value: "mon-provider" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), {
|
|
target: { value: "@ai-sdk/openai-compatible" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
|
|
target: { value: "https://api.mon-provider.example/v1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "mon-modele-1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), {
|
|
target: { value: "Mon Modèle" },
|
|
});
|
|
const apiKeyInput = screen.getByLabelText(
|
|
`${OPENCODE} api key`,
|
|
) as HTMLInputElement;
|
|
fireEvent.change(apiKeyInput, { target: { value: "sk-custom-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("mon-provider");
|
|
expect(opencode?.opencodeProvider?.model).toBe("mon-modele-1");
|
|
expect(opencode?.opencodeProvider?.custom).toEqual({
|
|
npm: "@ai-sdk/openai-compatible",
|
|
baseUrl: "https://api.mon-provider.example/v1",
|
|
displayName: "Mon Modèle",
|
|
});
|
|
});
|
|
expect(apiKeyInput.value).toBe("");
|
|
});
|
|
|
|
it("omits displayName and custom when left blank / not in custom mode", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
await goToCloudCustomMode();
|
|
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
|
|
target: { value: "mon-provider" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
|
|
target: { value: "https://api.mon-provider.example/v1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "mon-modele-1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "sk-custom-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?.custom?.displayName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('"← Choisir un provider du catalogue" switches back to the cascade', async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
await goToCloudCustomMode();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "← Choisir un provider du catalogue" }),
|
|
);
|
|
|
|
expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy();
|
|
expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull();
|
|
});
|
|
|
|
it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
{
|
|
id: "cfg-oc-custom-1",
|
|
name: "Mon provider via OpenCode",
|
|
command: "opencode",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
|
detect: "opencode --version",
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "openCode",
|
|
opencodeProvider: {
|
|
providerId: "mon-provider",
|
|
model: "mon-modele-1",
|
|
apiKeyRef: "secret-ref-2",
|
|
custom: {
|
|
npm: "@ai-sdk/openai-compatible",
|
|
baseUrl: "https://api.mon-provider.example/v1",
|
|
displayName: "Mon Modèle",
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const gateways = {
|
|
profile,
|
|
modelServer: new MockModelServerGateway(),
|
|
} as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard forceOpen />
|
|
</DIProvider>,
|
|
);
|
|
await waitForLoaded();
|
|
|
|
const row = within(
|
|
screen.getByLabelText("use Mon provider via OpenCode").closest("li")!,
|
|
);
|
|
expect(
|
|
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
|
|
"aria-checked",
|
|
),
|
|
).toBe("true");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement)
|
|
.value,
|
|
).toBe("mon-provider");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement)
|
|
.value,
|
|
).toBe("@ai-sdk/openai-compatible");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement)
|
|
.value,
|
|
).toBe("https://api.mon-provider.example/v1");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value,
|
|
).toBe("mon-modele-1");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement)
|
|
.value,
|
|
).toBe("Mon Modèle");
|
|
expect(
|
|
(row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement)
|
|
.value,
|
|
).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
|
const OPENCODE = "OpenCode + llama.cpp";
|
|
const CLONE1 = `${OPENCODE} (copy 1)`;
|
|
|
|
it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Only one OpenCode row to begin with (the seed reference).
|
|
expect(screen.getAllByText(OPENCODE).length).toBe(1);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
|
|
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
|
|
const added = await screen.findByLabelText(`use ${CLONE1}`);
|
|
expect((added as HTMLInputElement).checked).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
|
|
).toBe("http://localhost:8080/v1");
|
|
// Its name is editable per profile (identity is the id, not the name).
|
|
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
|
|
});
|
|
|
|
it("persists two distinct OpenCode profiles when both are selected", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Select the seed row and edit it.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-14b" },
|
|
});
|
|
|
|
// Add a second one and give it a different endpoint.
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
|
|
target: { value: "http://localhost:9191/v1" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
|
|
expect(opencode.length).toBe(2);
|
|
// Distinct ids (identity) and distinct endpoints.
|
|
expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
|
|
expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
|
|
"http://localhost:8080/v1",
|
|
"http://localhost:9191/v1",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("Duplicate clones a row carrying its current config over", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Edit the seed row, then duplicate it.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-7b" },
|
|
});
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
|
|
);
|
|
|
|
const dupName = `${OPENCODE} (copy)`;
|
|
await screen.findByLabelText(`use ${dupName}`);
|
|
// The duplicate inherits the edited model.
|
|
expect(
|
|
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
|
|
).toBe("qwen3-coder-7b");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const models = saved
|
|
.filter((p) => p.structuredAdapter === "openCode")
|
|
.map((p) => p.opencode?.model);
|
|
expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
|
|
});
|
|
});
|
|
|
|
it("editing the name round-trips on save", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
|
|
target: { value: "Fast local model" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.name).toBe("Fast local model");
|
|
});
|
|
});
|
|
|
|
it("reasoning/attachment round-trip on save (F36)", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
|
|
// Reasoning defaults on (backend effective default true); turn it off.
|
|
const reasoning = screen.getByLabelText(
|
|
`${CLONE1} reasoning`,
|
|
) as HTMLInputElement;
|
|
expect(reasoning.checked).toBe(true);
|
|
fireEvent.click(reasoning);
|
|
// Attachments default off; turn them on.
|
|
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.opencode?.reasoning).toBe(false);
|
|
expect(oc?.opencode?.attachment).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
|
|
// Seed a declared server so the dropdown offers it.
|
|
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 } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
|
|
// The old free-text field is gone; a dropdown replaces it.
|
|
expect(
|
|
screen.queryByLabelText(`${CLONE1} local model server id`),
|
|
).toBeNull();
|
|
await screen.findByLabelText(`${CLONE1} local model server`);
|
|
chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.opencode?.localModelServerId).toBe(server.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Ticket #28 — the wizard must survive a detection that never answers. When the
|
|
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
|
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
|
// `busy` must therefore never be tied to detection, or both buttons stay greyed
|
|
// out for good and the first run cannot be completed.
|
|
describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
|
|
/** A gateway whose `detectProfiles` promise stays pending forever. */
|
|
class HangingDetectGateway extends MockProfileGateway {
|
|
override detectProfiles(): Promise<ProfileAvailability[]> {
|
|
return new Promise<ProfileAvailability[]>(() => {});
|
|
}
|
|
}
|
|
|
|
it("keeps Save and Detect enabled once firstRunState answered", async () => {
|
|
renderWizard(new HangingDetectGateway());
|
|
await waitForLoaded();
|
|
|
|
// The rows are rendered, so the blocking load is over: both actions are live.
|
|
const save = screen.getByRole("button", { name: "Save and continue" });
|
|
const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
|
|
expect((save as HTMLButtonElement).disabled).toBe(false);
|
|
expect((detect as HTMLButtonElement).disabled).toBe(false);
|
|
|
|
// Detection is merely reported as in flight, never as a blocking state.
|
|
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
|
|
|
// Re-probing by hand must not freeze them either.
|
|
fireEvent.click(detect);
|
|
expect((save as HTMLButtonElement).disabled).toBe(false);
|
|
expect((detect as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it("still finishes the first run while detection hangs", async () => {
|
|
const profile = new HangingDetectGateway();
|
|
const { onDone } = renderWizard(profile, vi.fn());
|
|
await waitForLoaded();
|
|
|
|
// Nothing got pre-checked (detection never answered): tick a profile by hand.
|
|
fireEvent.click(screen.getByLabelText("use Claude Code"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
|
const saved = await profile.listProfiles();
|
|
expect(saved.map((p) => p.command)).toEqual(["claude"]);
|
|
});
|
|
|
|
it("drops the detecting flag on timeout even if the promise never settles", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
renderWizard(new HangingDetectGateway());
|
|
await waitForLoaded();
|
|
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
|
|
});
|
|
expect(screen.queryByRole("status")).toBeNull();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
|
/** A gateway whose first run is already done (profiles configured). */
|
|
async function configuredGateway() {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([]); // marks first run as done
|
|
return profile;
|
|
}
|
|
|
|
it("stays hidden once the first run is done (default)", async () => {
|
|
const profile = await configuredGateway();
|
|
const gateways = { profile } as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard />
|
|
</DIProvider>,
|
|
);
|
|
// Give the async first-run state time to resolve to `false`.
|
|
await waitFor(() =>
|
|
expect(screen.queryByLabelText("first run setup")).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it("renders when forced open (Settings ▸ Configure profiles)", async () => {
|
|
const profile = await configuredGateway();
|
|
const gateways = { profile } as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard forceOpen />
|
|
</DIProvider>,
|
|
);
|
|
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// Ticket #44 — reopening the wizard from Settings (`forceOpen` ⇒ edit mode) must
|
|
// pre-fill the already-configured profiles, pre-selected with their real config,
|
|
// so a save doesn't silently drop them and generic reference candidates don't
|
|
// take over. Dedup is by id only; several local OpenCode profiles are preserved.
|
|
describe("FirstRunWizard edit mode reopening (#44)", () => {
|
|
const injection = {
|
|
strategy: "conventionFile" as const,
|
|
target: "AGENTS.md",
|
|
};
|
|
function openCodeProfile(
|
|
id: string,
|
|
name: string,
|
|
baseURL: string,
|
|
model = "qwen3-coder-30b",
|
|
): AgentProfile {
|
|
return {
|
|
id,
|
|
name,
|
|
command: "opencode",
|
|
args: [],
|
|
contextInjection: injection,
|
|
detect: "opencode --version",
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "openCode",
|
|
opencode: { baseURL, apiKey: "sk-x", model },
|
|
};
|
|
}
|
|
|
|
function renderEdit(profile: MockProfileGateway) {
|
|
const gateways = {
|
|
profile,
|
|
modelServer: new MockModelServerGateway(),
|
|
} as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<FirstRunWizard forceOpen />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
it("pre-fills the configured profiles, pre-selected, with config intact", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
openCodeProfile("cfg-oc-1", "Local A", "http://localhost:9090/v1", "qwen3-coder-14b"),
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
|
|
// The configured OpenCode comes back checked, with its real endpoint/model.
|
|
expect(
|
|
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
|
).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
|
).toBe("http://localhost:9090/v1");
|
|
expect(
|
|
(screen.getByLabelText("Local A model") as HTMLInputElement).value,
|
|
).toBe("qwen3-coder-14b");
|
|
});
|
|
|
|
it("does not duplicate a configured reference (matched by id, configured wins)", async () => {
|
|
// The reference OpenCode (id `mock-opencode`) was edited and is now
|
|
// configured under the same id — it must appear exactly once, pre-selected.
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
openCodeProfile("mock-opencode", "OpenCode (edited)", "http://localhost:7777/v1"),
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
|
|
// Exactly one row for the edited OpenCode; the generic reference name is gone.
|
|
expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
|
|
expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
|
|
expect(
|
|
(screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps several distinct OpenCode profiles (same command, different ids)", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
|
|
// Both distinct profiles are present and pre-selected, none collapsed.
|
|
expect(
|
|
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
|
|
).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText("use Local B") as HTMLInputElement).checked,
|
|
).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
|
|
).toBe("http://localhost:8080/v1");
|
|
expect(
|
|
(screen.getByLabelText("Local B base url") as HTMLInputElement).value,
|
|
).toBe("http://localhost:9191/v1");
|
|
});
|
|
|
|
it("saving in edit mode does not drop configured profiles by omission", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
// Save straight away without touching anything.
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
expect(saved.map((p) => p.id).sort()).toEqual(["oc-1", "oc-2"]);
|
|
// Real config preserved through the round-trip.
|
|
expect(
|
|
saved.find((p) => p.id === "oc-1")?.opencode?.baseURL,
|
|
).toBe("http://localhost:8080/v1");
|
|
});
|
|
});
|
|
|
|
it("unchecking a configured row removes it on save (deliberate deletion)", async () => {
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
|
|
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
// Deselect Local B, then save — an explicit removal, not an omission.
|
|
fireEvent.click(screen.getByLabelText("use Local B"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
expect(saved.map((p) => p.id)).toEqual(["oc-1"]);
|
|
});
|
|
});
|
|
|
|
it("auto-detection in edit mode does not re-check or uncheck the initial selection", async () => {
|
|
// A configured Codex (not installed per the mock) reuses the reference id, so
|
|
// it dedups to one pre-selected row. Claude is installed but only a reference.
|
|
const profile = new MockProfileGateway();
|
|
await profile.configureProfiles([
|
|
{
|
|
id: "mock-codex",
|
|
name: "Codex Configured",
|
|
command: "codex",
|
|
args: [],
|
|
contextInjection: injection,
|
|
detect: "codex --version",
|
|
cwdTemplate: "{projectRoot}",
|
|
},
|
|
]);
|
|
|
|
renderEdit(profile);
|
|
await waitForLoaded();
|
|
|
|
// Wait until detection has filled availability (Claude reads as installed).
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByLabelText("Claude Code availability").textContent,
|
|
).toMatch(/installed/),
|
|
);
|
|
|
|
// Selection is untouched by detection: the configured (uninstalled) Codex
|
|
// stays checked; the installed Claude reference stays UNchecked.
|
|
expect(
|
|
(screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
|
|
).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
|
).toBe(false);
|
|
});
|
|
});
|