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