feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)

Donne à l'utilisateur la surface pour activer le serveur depuis l'app.

- `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`.
- `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ;
  adapters mock et http/unsupported alignés (le mode web n'expose pas le
  contrôle du serveur qui l'héberge).
- `ProjectsView` : le `showSettings: boolean` devient une navigation interne
  `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles »
  disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu
  d'ajouter un `PanelId`.

CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite
de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux
champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette
loopback comme unspecified. Construit selon la spec, chaque save et chaque start
en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose
donc un select alimenté par `candidateLanAddresses` fourni par le backend :
la règle « le frontend n'invente jamais une IP » tient.

QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 ·
`npx vitest run` 87 files / 789 passed, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:43:37 +02:00
parent 49e1d5a157
commit 7efa634f23
16 changed files with 1458 additions and 31 deletions

View File

@ -0,0 +1,209 @@
/**
* Ticket #68 — `Settings → Deployment`, driven through the real `DIProvider`
* and the mock gateway (no backend).
*
* These pin the UX invariants the screen exists for, not its styling: the mode
* is a radio choice with consequences, the authorized-proxy field is explicitly
* *not* a listen address, addresses come from the backend, a refusal is
* actionable, and the pairing code is runtime-only.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MockDesktopServerGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { DeploymentSettings } from "./DeploymentSettings";
function renderView(desktopServer = new MockDesktopServerGateway()) {
const gateways = { desktopServer } as unknown as Gateways;
return {
desktopServer,
...render(
<DIProvider gateways={gateways}>
<DeploymentSettings />
</DIProvider>,
),
};
}
/** Waits past the hook's preview debounce. */
async function settle() {
await screen.findByRole("radiogroup", { name: "exposure mode" });
await waitFor(() => expect(screen.getByLabelText("Port")).toBeTruthy());
}
function selectMode(title: string) {
fireEvent.click(screen.getByRole("radio", { name: new RegExp(title) }));
}
describe("DeploymentSettings", () => {
it("offers the three exposure modes as radio cards with their consequences", async () => {
renderView();
await settle();
const group = screen.getByRole("radiogroup", { name: "exposure mode" });
expect(within(group).getAllByRole("radio")).toHaveLength(3);
// The plain-language consequence is part of the choice, not a tooltip.
expect(
within(group).getByText(/Remote devices cannot connect/),
).toBeTruthy();
expect(
within(group).getByText(/same machine as IdeA Desktop/),
).toBeTruthy();
expect(
within(group).getByText(/only accept traffic from that proxy/),
).toBeTruthy();
});
it("shows no exposure fields in 'This computer only'", async () => {
renderView();
await settle();
// Default is localOnly: nothing to configure, nothing to get wrong.
expect(screen.queryByLabelText("Public origin")).toBeNull();
expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull();
expect(screen.queryByLabelText("LAN address to bind")).toBeNull();
});
it("asks only for a public origin when the proxy is on this computer", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on this computer");
expect(await screen.findByLabelText("Public origin")).toBeTruthy();
// The proxy is local, so there is nothing to authorize and nothing to bind.
expect(screen.queryByLabelText("Authorized proxy IP/CIDR")).toBeNull();
expect(screen.queryByLabelText("LAN address to bind")).toBeNull();
});
it("explains that the authorized proxy is not a listen address, and warns about the mode", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on another machine");
// This help text is the whole point of the screen: it corrects the
// "that's where IdeA listens" misreading.
expect(
await screen.findByText(
"This is not where IdeA listens. It is the machine allowed to contact IdeA.",
),
).toBeTruthy();
// The permanent warning about the silent-timeout failure mode.
expect(
screen.getByText(/the proxy may time out without an IdeA error/),
).toBeTruthy();
});
it("offers LAN addresses from the backend rather than deriving any", async () => {
const gateway = new MockDesktopServerGateway();
const preview = vi.spyOn(gateway, "previewExposure");
renderView(gateway);
await settle();
selectMode("Remote access, proxy on another machine");
const select = await screen.findByLabelText("LAN address to bind");
const offered = within(select as HTMLElement)
.getAllByRole("option")
.map((o) => (o as HTMLOptionElement).value)
.filter(Boolean);
// Exactly the gateway's candidates — the UI invents nothing.
const fromBackend = (await preview.mock.results[0]!.value).candidateLanAddresses;
expect(offered).toEqual(fromBackend);
});
it("surfaces the backend's refusal as a concrete correction", async () => {
renderView();
await settle();
// A remote mode with no origin: the backend says exactly what to fix.
selectMode("Remote access, proxy on this computer");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/requires publicOrigin/);
});
it("shows the upstream to paste, read-only, once the config is valid", async () => {
renderView();
await settle();
selectMode("Remote access, proxy on this computer");
fireEvent.change(await screen.findByLabelText("Public origin"), {
target: { value: "https://idea.example.com" },
});
const upstream = await screen.findByRole("button", {
name: "copy upstream url",
});
expect(upstream).toBeTruthy();
// The upstream is IdeA-provided, never a field the user edits.
expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull();
});
it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => {
renderView();
await settle();
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
expect(
await screen.findByRole("button", { name: "copy pairing code" }),
).toBeTruthy();
expect(
screen.getByText(/Temporary code. It disappears when the server stops/),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy(),
);
});
it("starts the server and reports the local URL", async () => {
renderView();
await settle();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect(screen.getByText(/Local URL:/)).toBeTruthy();
});
it("persists the draft before starting, so what runs is what is shown", async () => {
const gateway = new MockDesktopServerGateway();
const save = vi.spyOn(gateway, "saveExposureSettings");
renderView(gateway);
await settle();
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "18080" } });
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(save.mock.calls[0]![0]).toMatchObject({ port: 18080 });
await waitFor(() =>
expect(screen.getByText("http://127.0.0.1:18080")).toBeTruthy(),
);
});
it("reports a failed start with the backend message", async () => {
const gateway = new MockDesktopServerGateway();
renderView(gateway);
await settle();
gateway.setStatus({
state: "failed",
error: { code: "INVALID", message: "port 17373 already in use" },
});
await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy());
expect(screen.getByRole("alert").textContent).toMatch(/already in use/);
});
});