Files
IdeA/frontend/src/features/settings/DeploymentSettings.test.tsx
Blomios 3f1e132e88 feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)
Expose la gestion des appareils appairés introduite en B1-B4, sur une
surface unique partagée par le web et le desktop.

- Écran Appareils : liste, renommage, révocation unitaire ou globale,
  activité formatée, panneau de code éphémère.
- Le parcours d'appairage demande un nom d'appareil, pour qu'une
  révocation porte sur quelque chose d'identifiable par l'utilisateur.
- Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port
  restant le seul contrat connu de la feature.

Les erreurs sont mappées localement et le message du serveur n'est jamais
affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour
qui teste des codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:27:06 +02:00

211 lines
7.7 KiB
TypeScript

/**
* 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 no pairing code is ever shown here (#77).
*/
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("never shows a pairing code, running or not (#77)", async () => {
// A code is no longer a property of a running server: it exists only when
// asked for. Starting the server must not make one appear here — this is
// what the old "runtime-only code" test asserted, and it can no longer be
// true of any backend response.
renderView();
await settle();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
});
it("points to where pairing now lives instead of dead-ending", async () => {
// Someone who just started the server from this screen needs to know where
// to go next; silence would be a cul-de-sac.
renderView();
await settle();
const pairing = screen.getByText(/Pairing is managed in/);
expect(within(pairing).getByText("Settings → Appareils")).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/);
});
});