diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 03caf1a..6bde8e6 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi } from "vitest"; import { + act, render, screen, waitFor, @@ -13,9 +14,11 @@ import { } 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(), @@ -301,6 +304,69 @@ describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", }); }); +// 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() { diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 1476ed0..a84a224 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -78,6 +78,8 @@ export function FirstRunWizard({ )} + {/* `detecting` deliberately does NOT disable this button (ticket #28): + detection is best-effort and may never answer. */} + {vm.detecting && ( + + Detecting… + + )}