feat(first-run): conserver les infos du profil courant à l'édition (#44)

Extrait la logique de dérivation des entrées du wizard dans un module
dédié `wizardEntries` afin de préserver les informations du profil
courant lors de l'ouverture de l'affichage d'édition des profils, au
lieu de repartir d'un état vide. Couvre le comportement par des tests
unitaires (wizardEntries + FirstRunWizard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:53:46 +02:00
parent 76560ab101
commit 8cdc44de6e
5 changed files with 476 additions and 24 deletions

View File

@ -14,7 +14,11 @@ import {
} from "@testing-library/react";
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
import type {
AgentProfile,
LocalModelServerConfig,
ProfileAvailability,
} from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard";
@ -553,3 +557,186 @@ describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
});
});
// Ticket #44 — reopening the wizard from Settings (`forceOpen` ⇒ edit mode) must
// pre-fill the already-configured profiles, pre-selected with their real config,
// so a save doesn't silently drop them and generic reference candidates don't
// take over. Dedup is by id only; several local OpenCode profiles are preserved.
describe("FirstRunWizard edit mode reopening (#44)", () => {
const injection = {
strategy: "conventionFile" as const,
target: "AGENTS.md",
};
function openCodeProfile(
id: string,
name: string,
baseURL: string,
model = "qwen3-coder-30b",
): AgentProfile {
return {
id,
name,
command: "opencode",
args: [],
contextInjection: injection,
detect: "opencode --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencode: { baseURL, apiKey: "sk-x", model },
};
}
function renderEdit(profile: MockProfileGateway) {
const gateways = {
profile,
modelServer: new MockModelServerGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<FirstRunWizard forceOpen />
</DIProvider>,
);
}
it("pre-fills the configured profiles, pre-selected, with config intact", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
openCodeProfile("cfg-oc-1", "Local A", "http://localhost:9090/v1", "qwen3-coder-14b"),
]);
renderEdit(profile);
await waitForLoaded();
// The configured OpenCode comes back checked, with its real endpoint/model.
expect(
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
).toBe("http://localhost:9090/v1");
expect(
(screen.getByLabelText("Local A model") as HTMLInputElement).value,
).toBe("qwen3-coder-14b");
});
it("does not duplicate a configured reference (matched by id, configured wins)", async () => {
// The reference OpenCode (id `mock-opencode`) was edited and is now
// configured under the same id — it must appear exactly once, pre-selected.
const profile = new MockProfileGateway();
await profile.configureProfiles([
openCodeProfile("mock-opencode", "OpenCode (edited)", "http://localhost:7777/v1"),
]);
renderEdit(profile);
await waitForLoaded();
// Exactly one row for the edited OpenCode; the generic reference name is gone.
expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
expect(
(screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
).toBe(true);
});
it("keeps several distinct OpenCode profiles (same command, different ids)", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
]);
renderEdit(profile);
await waitForLoaded();
// Both distinct profiles are present and pre-selected, none collapsed.
expect(
(screen.getByLabelText("use Local A") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("use Local B") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("Local A base url") as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
expect(
(screen.getByLabelText("Local B base url") as HTMLInputElement).value,
).toBe("http://localhost:9191/v1");
});
it("saving in edit mode does not drop configured profiles by omission", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
]);
renderEdit(profile);
await waitForLoaded();
// Save straight away without touching anything.
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.id).sort()).toEqual(["oc-1", "oc-2"]);
// Real config preserved through the round-trip.
expect(
saved.find((p) => p.id === "oc-1")?.opencode?.baseURL,
).toBe("http://localhost:8080/v1");
});
});
it("unchecking a configured row removes it on save (deliberate deletion)", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
]);
renderEdit(profile);
await waitForLoaded();
// Deselect Local B, then save — an explicit removal, not an omission.
fireEvent.click(screen.getByLabelText("use Local B"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.id)).toEqual(["oc-1"]);
});
});
it("auto-detection in edit mode does not re-check or uncheck the initial selection", async () => {
// A configured Codex (not installed per the mock) reuses the reference id, so
// it dedups to one pre-selected row. Claude is installed but only a reference.
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "mock-codex",
name: "Codex Configured",
command: "codex",
args: [],
contextInjection: injection,
detect: "codex --version",
cwdTemplate: "{projectRoot}",
},
]);
renderEdit(profile);
await waitForLoaded();
// Wait until detection has filled availability (Claude reads as installed).
await waitFor(() =>
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/),
);
// Selection is untouched by detection: the configured (uninstalled) Codex
// stays checked; the installed Claude reference stays UNchecked.
expect(
(screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
).toBe(true);
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
).toBe(false);
});
});