/** * 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, } 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 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("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 — 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 — 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(); const select = await screen.findByLabelText(`${CLONE1} local model server`); fireEvent.change(select, { target: { value: server.id } }); 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); }); });