Files
IdeaSDK/frontend/src/features/first-run/FirstRunWizard.test.tsx
Blomios 0389e2e0b0 fix(first-run): découple busy de la détection des CLI (#28)
Même backend réparé, le wizard restait à la merci d'une détection qui ne
répond pas : `reload()` et `detect()` awaitaient `detectProfiles` sous le
même drapeau `busy` qui grise « Save and continue » et « Detect installed
CLIs ». Une promesse IPC jamais résolue laissait donc les deux boutons
grisés à vie — sans issue pour l'utilisateur.

La détection redevient ce qu'elle est : une étape best-effort, jamais
bloquante.

- `busy` ne garde plus que ce dont le wizard ne peut pas se passer
  (`firstRunState`) ou qui mute l'état (`configureProfiles`). Il ne dépend
  plus jamais de `detectProfiles`. L'invariant est documenté en tête de
  module.
- Un drapeau `detecting` distinct suit la sonde et n'inhibe aucune action.
  Il est relâché par un timer (`DETECT_TIMEOUT_MS`), jamais par la seule
  promesse : celle-ci peut rester pendante indéfiniment.
- Un identifiant de tour monotone (`detectRun`) invalide les tours périmés
  et ceux qui survivent au démontage, évitant un `setState` hors montage.
- `reload()` rend les lignes immédiatement puis lance la détection en tâche
  détachée. Si elle échoue, les lignes restent cochables à la main ; seul le
  bouton explicite remonte l'erreur.

Tests: vitest 59 fichiers / 569 tests verts, tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:11:24 +02:00

403 lines
15 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,
} 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(
<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("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<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();
});
});