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>
403 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|