Étend ServerExposureSettings avec autoStart (défaut false, mock aligné sur le backend). useDeployment expose setAutoStart, qui persiste immédiatement le draft sans jamais appeler start() ni toucher le comportement de stop(), et ne met à jour l'état local qu'après confirmation de la sauvegarde (même piste de validation que start/save). DeploymentSettings ajoute la case à cocher sous le panneau Serveur, et des actions « Modifier le port »/« Réessayer » quand le statut échoue avec le code PORT_IN_USE stabilisé côté backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
362 lines
14 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).
|
|
*
|
|
* Labels asserted here are French (ticket #78 — UX decision: uniform French UI
|
|
* per surface, technical acronyms like URL/LAN/IP/CIDR/HTTPS excepted).
|
|
*/
|
|
|
|
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: "mode d'accès réseau" });
|
|
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: "mode d'accès réseau" });
|
|
expect(within(group).getAllByRole("radio")).toHaveLength(3);
|
|
|
|
// The plain-language consequence is part of the choice, not a tooltip.
|
|
expect(
|
|
within(group).getByText(/ne peuvent pas se connecter/),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(group).getByText(/même machine qu'IdeA Desktop/),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(group).getByText(/n'acceptera que le trafic provenant de ce proxy/),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("shows no exposure fields in 'Cet ordinateur uniquement'", async () => {
|
|
renderView();
|
|
await settle();
|
|
|
|
// Default is localOnly: nothing to configure, nothing to get wrong.
|
|
expect(screen.queryByLabelText("Origine publique")).toBeNull();
|
|
expect(screen.queryByLabelText("IP/CIDR du proxy autorisé")).toBeNull();
|
|
expect(screen.queryByLabelText("Adresse LAN d'écoute")).toBeNull();
|
|
});
|
|
|
|
it("asks only for a public origin when the proxy is on this computer", async () => {
|
|
renderView();
|
|
await settle();
|
|
selectMode("Accès distant, proxy sur cet ordinateur");
|
|
|
|
expect(await screen.findByLabelText("Origine publique")).toBeTruthy();
|
|
// The proxy is local, so there is nothing to authorize and nothing to bind.
|
|
expect(screen.queryByLabelText("IP/CIDR du proxy autorisé")).toBeNull();
|
|
expect(screen.queryByLabelText("Adresse LAN d'écoute")).toBeNull();
|
|
});
|
|
|
|
it("explains that the authorized proxy is not a listen address, and warns about the mode", async () => {
|
|
renderView();
|
|
await settle();
|
|
selectMode("Accès distant, proxy sur une autre machine");
|
|
|
|
// This help text is the whole point of the screen: it corrects the
|
|
// "that's where IdeA listens" misreading.
|
|
expect(
|
|
await screen.findByText(
|
|
"Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA.",
|
|
),
|
|
).toBeTruthy();
|
|
|
|
// The permanent warning about the silent-timeout failure mode.
|
|
expect(
|
|
screen.getByText(/le proxy peut expirer sans erreur IdeA/),
|
|
).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("Accès distant, proxy sur une autre machine");
|
|
|
|
const select = await screen.findByLabelText("Adresse LAN d'écoute");
|
|
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("Accès distant, proxy sur cet ordinateur");
|
|
|
|
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("Accès distant, proxy sur cet ordinateur");
|
|
fireEvent.change(await screen.findByLabelText("Origine publique"), {
|
|
target: { value: "https://idea.example.com" },
|
|
});
|
|
|
|
const upstream = await screen.findByRole("button", {
|
|
name: "copier l'URL upstream",
|
|
});
|
|
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: "Démarrer" }));
|
|
await waitFor(() => expect(screen.getByText("En cours d'exécution")).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(/L'appairage est géré dans/);
|
|
expect(within(pairing).getByText("Paramètres → Appareils")).toBeTruthy();
|
|
});
|
|
|
|
it("starts the server and reports the local URL", async () => {
|
|
renderView();
|
|
await settle();
|
|
fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
|
|
|
|
await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy());
|
|
expect(screen.getByText(/URL locale :/)).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: "Démarrer" }));
|
|
|
|
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("Échec")).toBeTruthy());
|
|
expect(screen.getByRole("alert").textContent).toMatch(/already in use/);
|
|
});
|
|
|
|
describe("auto-start at launch (ticket #89)", () => {
|
|
it("shows the checkbox unchecked by default, with its exact label and help text", async () => {
|
|
renderView();
|
|
await settle();
|
|
|
|
const checkbox = screen.getByRole("checkbox", {
|
|
name: "Démarrer le serveur au lancement d'IdeA",
|
|
});
|
|
expect(checkbox).toHaveProperty("checked", false);
|
|
expect(
|
|
screen.getByText(
|
|
"IdeA utilisera les réglages réseau enregistrés ci-dessous au prochain démarrage de l'application desktop.",
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("persists autoStart:true on toggle ON without starting the server", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
const save = vi.spyOn(gateway, "saveExposureSettings");
|
|
const start = vi.spyOn(gateway, "start");
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
|
|
);
|
|
|
|
await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ autoStart: true })));
|
|
expect(start).not.toHaveBeenCalled();
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
|
|
).toHaveProperty("checked", true),
|
|
);
|
|
expect(screen.getByText("Arrêté")).toBeTruthy();
|
|
});
|
|
|
|
it("persists autoStart:false on toggle OFF", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
const save = vi.spyOn(gateway, "saveExposureSettings");
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
const checkbox = screen.getByRole("checkbox", {
|
|
name: "Démarrer le serveur au lancement d'IdeA",
|
|
});
|
|
fireEvent.click(checkbox);
|
|
await waitFor(() => expect(checkbox).toHaveProperty("checked", true));
|
|
|
|
fireEvent.click(checkbox);
|
|
await waitFor(() =>
|
|
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ autoStart: false })),
|
|
);
|
|
await waitFor(() => expect(checkbox).toHaveProperty("checked", false));
|
|
});
|
|
|
|
it("does not persist the toggle when the draft is otherwise invalid", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
renderView(gateway);
|
|
await settle();
|
|
// A remote mode with no public origin — saveExposureSettings must reject.
|
|
selectMode("Accès distant, proxy sur cet ordinateur");
|
|
|
|
const checkbox = await screen.findByRole("checkbox", {
|
|
name: "Démarrer le serveur au lancement d'IdeA",
|
|
});
|
|
fireEvent.click(checkbox);
|
|
|
|
await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/publicOrigin/));
|
|
// Rejected save: the checkbox must not silently flip to checked.
|
|
expect(checkbox).toHaveProperty("checked", false);
|
|
});
|
|
|
|
it("leaves autoStart untouched when the server is stopped manually", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
const save = vi.spyOn(gateway, "saveExposureSettings");
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
|
|
);
|
|
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Démarrer" }));
|
|
await waitFor(() => expect(screen.getByText("En cours d'exécution")).toBeTruthy());
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Arrêter" }));
|
|
await waitFor(() => expect(screen.getByText("Arrêté")).toBeTruthy());
|
|
|
|
// `stop()` never calls `saveExposureSettings` — autoStart is untouched.
|
|
expect(save).toHaveBeenCalledTimes(2); // the toggle, then the pre-start persist
|
|
expect(
|
|
screen.getByRole("checkbox", { name: "Démarrer le serveur au lancement d'IdeA" }),
|
|
).toHaveProperty("checked", true);
|
|
});
|
|
});
|
|
|
|
describe("port-conflict actionable error (ticket #89)", () => {
|
|
it("shows the dedicated message with « Modifier le port » and « Réessayer » actions", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
gateway.setStatus({
|
|
state: "failed",
|
|
error: { code: "PORT_IN_USE", message: "address already in use" },
|
|
});
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe(
|
|
"Le serveur n'a pas démarré automatiquement. address already in use",
|
|
);
|
|
expect(screen.getByRole("button", { name: "Modifier le port" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
|
});
|
|
|
|
it("« Modifier le port » focuses the Port field", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
gateway.setStatus({
|
|
state: "failed",
|
|
error: { code: "PORT_IN_USE", message: "address already in use" },
|
|
});
|
|
await screen.findByRole("button", { name: "Modifier le port" });
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Modifier le port" }));
|
|
expect(screen.getByLabelText("Port")).toBe(document.activeElement);
|
|
});
|
|
|
|
it("« Réessayer » calls start() again", async () => {
|
|
const gateway = new MockDesktopServerGateway();
|
|
const start = vi.spyOn(gateway, "start");
|
|
renderView(gateway);
|
|
await settle();
|
|
|
|
gateway.setStatus({
|
|
state: "failed",
|
|
error: { code: "PORT_IN_USE", message: "address already in use" },
|
|
});
|
|
fireEvent.click(await screen.findByRole("button", { name: "Réessayer" }));
|
|
|
|
await waitFor(() => expect(start).toHaveBeenCalled());
|
|
});
|
|
});
|
|
});
|