/** * 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( , ), }; } /** 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()); }); }); });