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>
743 lines
26 KiB
TypeScript
743 lines
26 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 { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
|
import type {
|
|
AgentProfile,
|
|
LocalModelServerConfig,
|
|
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(),
|
|
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
|
) {
|
|
const gateways = { profile, modelServer } as unknown as Gateways;
|
|
return {
|
|
profile,
|
|
modelServer,
|
|
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 — OpenCode + llama.cpp local profile", () => {
|
|
const OPENCODE = "OpenCode + llama.cpp";
|
|
|
|
it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
expect(screen.getByText(OPENCODE)).toBeTruthy();
|
|
// The OpenCode fields are rendered, pre-filled from the reference.
|
|
expect(
|
|
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
|
|
).toBe("http://localhost:8080/v1");
|
|
expect(
|
|
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
|
|
).toBe("qwen3-coder-30b");
|
|
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
|
|
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
|
|
});
|
|
|
|
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
|
|
expect(apiKey.value).toBe("sk-no-key");
|
|
|
|
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
|
|
// env-var-name error appears.
|
|
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
|
expect(apiKey.value).toBe("sk-secret-123");
|
|
expect(
|
|
screen.queryByText(/valid env var name/i),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("flags an invalid base URL scheme inline", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
|
|
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
|
|
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
|
});
|
|
|
|
it("flags an empty model inline", async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "" },
|
|
});
|
|
expect(screen.getByText(/model is required/i)).toBeTruthy();
|
|
});
|
|
|
|
it("persists the edited OpenCode config round-trip when selected and saved", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Select the local model and edit its base URL, model and API key.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
|
|
target: { value: "http://localhost:9090/v1" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-14b" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "sk-local" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.find((p) => p.command === "opencode");
|
|
expect(opencode?.structuredAdapter).toBe("openCode");
|
|
expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
|
expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
|
|
expect(opencode?.opencode?.apiKey).toBe("sk-local");
|
|
});
|
|
});
|
|
|
|
it("clearing the API key drops it (optional, omitted when blank)", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
|
target: { value: "" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.find((p) => p.command === "opencode");
|
|
expect(opencode?.opencode?.apiKey).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
|
const OPENCODE = "OpenCode + llama.cpp";
|
|
const CLONE1 = `${OPENCODE} (copy 1)`;
|
|
|
|
it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
|
|
renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Only one OpenCode row to begin with (the seed reference).
|
|
expect(screen.getAllByText(OPENCODE).length).toBe(1);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
|
|
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
|
|
const added = await screen.findByLabelText(`use ${CLONE1}`);
|
|
expect((added as HTMLInputElement).checked).toBe(true);
|
|
expect(
|
|
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
|
|
).toBe("http://localhost:8080/v1");
|
|
// Its name is editable per profile (identity is the id, not the name).
|
|
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
|
|
});
|
|
|
|
it("persists two distinct OpenCode profiles when both are selected", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Select the seed row and edit it.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-14b" },
|
|
});
|
|
|
|
// Add a second one and give it a different endpoint.
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
|
|
target: { value: "http://localhost:9191/v1" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
|
|
expect(opencode.length).toBe(2);
|
|
// Distinct ids (identity) and distinct endpoints.
|
|
expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
|
|
expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
|
|
"http://localhost:8080/v1",
|
|
"http://localhost:9191/v1",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("Duplicate clones a row carrying its current config over", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
// Edit the seed row, then duplicate it.
|
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
|
target: { value: "qwen3-coder-7b" },
|
|
});
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
|
|
);
|
|
|
|
const dupName = `${OPENCODE} (copy)`;
|
|
await screen.findByLabelText(`use ${dupName}`);
|
|
// The duplicate inherits the edited model.
|
|
expect(
|
|
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
|
|
).toBe("qwen3-coder-7b");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const models = saved
|
|
.filter((p) => p.structuredAdapter === "openCode")
|
|
.map((p) => p.opencode?.model);
|
|
expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
|
|
});
|
|
});
|
|
|
|
it("editing the name round-trips on save", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
|
|
target: { value: "Fast local model" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.name).toBe("Fast local model");
|
|
});
|
|
});
|
|
|
|
it("reasoning/attachment round-trip on save (F36)", async () => {
|
|
const { profile } = renderWizard();
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
|
|
// Reasoning defaults on (backend effective default true); turn it off.
|
|
const reasoning = screen.getByLabelText(
|
|
`${CLONE1} reasoning`,
|
|
) as HTMLInputElement;
|
|
expect(reasoning.checked).toBe(true);
|
|
fireEvent.click(reasoning);
|
|
// Attachments default off; turn them on.
|
|
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.opencode?.reasoning).toBe(false);
|
|
expect(oc?.opencode?.attachment).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
|
|
// Seed a declared server so the dropdown offers it.
|
|
const modelServer = new MockModelServerGateway();
|
|
const server: LocalModelServerConfig = {
|
|
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
kind: "llamaCpp",
|
|
name: "Local A",
|
|
baseURL: "http://localhost:8080/v1",
|
|
port: 8080,
|
|
servedModelName: "qwen3-coder-30b",
|
|
host: "127.0.0.1",
|
|
jinja: false,
|
|
args: [],
|
|
autoStart: false,
|
|
stopPolicy: "stopOnAppExit",
|
|
};
|
|
await modelServer.saveModelServer(server);
|
|
|
|
const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
|
|
await waitForLoaded();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
|
);
|
|
await screen.findByLabelText(`use ${CLONE1}`);
|
|
|
|
// The old free-text field is gone; a dropdown replaces it.
|
|
expect(
|
|
screen.queryByLabelText(`${CLONE1} local model server id`),
|
|
).toBeNull();
|
|
const select = await screen.findByLabelText(`${CLONE1} local model server`);
|
|
fireEvent.change(select, { target: { value: server.id } });
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
|
await waitFor(async () => {
|
|
const saved = await profile.listProfiles();
|
|
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
|
expect(oc?.opencode?.localModelServerId).toBe(server.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|