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:
@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user