/** * 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 { MockProfileGateway } from "@/adapters/mock"; import type { 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(), ) { const gateways = { profile } as unknown as Gateways; return { profile, 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 — OpenAI-compatible local/LAN profile (ticket #14)", () => { const OLLAMA = "Ollama / OpenAI-compatible local model"; it("offers the local model as a selectable, pre-filled HTTP config row", async () => { renderWizard(); await waitForLoaded(); expect(screen.getByText(OLLAMA)).toBeTruthy(); // The structured HTTP fields are rendered, pre-filled from the reference. expect( (screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value, ).toBe("http://localhost:11434/v1"); expect( (screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value, ).toBe("qwen2.5-coder"); // Claude/Codex rows must NOT grow HTTP fields (additive, no regression). expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull(); }); it("labels the API key field as an env var NAME and flags a raw key", async () => { renderWizard(); await waitForLoaded(); const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement; // The label/placeholder make the env-var-name intent explicit. expect(apiKey.placeholder.toLowerCase()).toContain("variable name"); // A raw secret is not a valid env var identifier ⇒ inline error. fireEvent.change(apiKey, { target: { value: "sk-secret-123" } }); expect( screen.getByText(/valid env var name \(not the key itself\)/i), ).toBeTruthy(); // A proper env var name clears the error. fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } }); expect( screen.queryByText(/valid env var name \(not the key itself\)/i), ).toBeNull(); }); it("flags an invalid endpoint scheme inline", async () => { renderWizard(); await waitForLoaded(); const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`); fireEvent.change(endpoint, { target: { value: "ftp://oops" } }); expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy(); }); it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => { renderWizard(); await waitForLoaded(); expect( (screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value, ).toBe("120000"); expect( (screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value, ).toBe("5000"); expect( (screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement) .value, ).toBe("16"); }); it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => { const { profile } = renderWizard(); await waitForLoaded(); fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { target: { value: "90000" }, }); fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), { target: { value: "3000" }, }); fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), { target: { value: "8" }, }); fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); await waitFor(async () => { const saved = await profile.listProfiles(); const ollama = saved.find((p) => p.command === "openai-compatible"); expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000); expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000); expect(ollama?.chatHttp?.maxToolIterations).toBe(8); }); }); it("flags a zero timeout inline (mirror of the backend guard)", async () => { renderWizard(); await waitForLoaded(); fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { target: { value: "0" }, }); expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy(); }); it("persists the edited HTTP config round-trip when selected and saved", async () => { const { profile } = renderWizard(); await waitForLoaded(); // Select the local model and edit its model name. fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), { target: { value: "qwen3" }, }); fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), { target: { value: "LAN_KEY" }, }); fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); await waitFor(async () => { const saved = await profile.listProfiles(); const ollama = saved.find((p) => p.command === "openai-compatible"); expect(ollama?.structuredAdapter).toBe("openAiCompatible"); expect(ollama?.chatHttp?.model).toBe("qwen3"); expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY"); // The endpoint round-trips untouched. expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1"); }); }); }); // 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(); }); });