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>
This commit is contained in:
2026-07-08 08:11:24 +02:00
parent 3abf2fce98
commit 0389e2e0b0
3 changed files with 172 additions and 38 deletions

View File

@ -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<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() {