/** * 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( , ), }; } 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( , ); 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( , ); 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( , ); 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 { return new Promise(() => {}); } } 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( , ); // 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( , ); 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( , ); } 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); }); });