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:
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal file
209
frontend/src/features/settings/DeploymentSettings.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user