Files
IdeaSDK/frontend/src/features/first-run/FirstRunWizard.test.tsx
Blomios dbaf6fe2f4 fix(model-servers): autorise les espaces dans le champ Arguments supplémentaires llama.cpp
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>
2026-08-01 00:12:07 +02:00

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);
});
});