feat(frontend): démarrage auto du serveur + erreur port occupé actionnable (#89)

É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>
This commit is contained in:
2026-07-21 18:38:40 +02:00
parent cb2d0c2d44
commit e9b01795d1
6 changed files with 273 additions and 8 deletions

View File

@ -1410,6 +1410,7 @@ export class MockModelServerGateway implements ModelServerGateway {
/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
mode: "localOnly",
autoStart: false,
port: 17373,
trustedProxies: [],
};

View File

@ -179,6 +179,12 @@ export type ServerExposureMode =
*/
export interface ServerExposureSettings {
mode: ServerExposureMode;
/**
* Whether the embedded server should start automatically when IdeA Desktop
* boots (ticket #89), using these persisted settings. Applied at app launch
* only — never implied by a manual `start()`/`stop()` call.
*/
autoStart: boolean;
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
port: number;
/** Public HTTPS origin, required by both remote modes. */

View File

@ -210,4 +210,152 @@ describe("DeploymentSettings", () => {
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());
});
});
});

View File

@ -23,7 +23,7 @@
* a dead end.
*/
import { useState } from "react";
import { useRef, useState } from "react";
import type { ServerExposureMode } from "@/domain";
import { Button, Field, Input, Panel, cn } from "@/shared";
@ -105,6 +105,9 @@ function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string
export function DeploymentSettings() {
const vm = useDeployment();
// "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the
// simplest correct answer for a cross-panel affordance on one scrolled page.
const portInputRef = useRef<HTMLInputElement>(null);
if (!vm.ready || !vm.settings) {
return (
@ -170,17 +173,72 @@ export function DeploymentSettings() {
</p>
)}
{/* A failure carries the backend's message: it is the correction. */}
{/* A failure carries the backend's message: it is the correction.
A port conflict (#89 — most often surfaced by auto-start at
launch, but shown the same way for a manual start) gets a
dedicated, actionable message instead of the generic one. */}
{status.state === "failed" && status.error && (
<p role="alert" className="text-sm text-danger">
{status.error.message}
</p>
status.error.code === "PORT_IN_USE" ? (
<div className="flex flex-col gap-2">
<p role="alert" className="text-sm text-danger">
Le serveur n'a pas démarré automatiquement. {status.error.message}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
portInputRef.current?.scrollIntoView?.({
behavior: "smooth",
block: "center",
});
portInputRef.current?.focus();
}}
>
Modifier le port
</Button>
<Button
size="sm"
onClick={() => void vm.start()}
disabled={vm.busy}
>
Réessayer
</Button>
</div>
</div>
) : (
<p role="alert" className="text-sm text-danger">
{status.error.message}
</p>
)
)}
{vm.actionError && (
<p role="alert" className="text-sm text-danger">
{vm.actionError}
</p>
)}
{/* Auto-start at launch (#89) — a persistence-only toggle, never a
start trigger; it never runs the server right now. */}
<label className="flex cursor-pointer items-start gap-2 pt-2">
<input
type="checkbox"
className="mt-0.5"
aria-label="Démarrer le serveur au lancement d'IdeA"
checked={settings.autoStart}
disabled={vm.autoStartBusy}
onChange={(e) => void vm.setAutoStart(e.target.checked)}
/>
<span className="flex flex-col gap-0.5">
<span className="text-sm text-content">
Démarrer le serveur au lancement d'IdeA
</span>
<span className="text-xs text-muted">
IdeA utilisera les réglages réseau enregistrés ci-dessous au
prochain démarrage de l'application desktop.
</span>
</span>
</label>
</div>
</Panel>
@ -225,6 +283,7 @@ export function DeploymentSettings() {
<Field label="Port">
{({ id }) => (
<Input
ref={portInputRef}
id={id}
type="number"
value={String(settings.port)}

View File

@ -42,8 +42,20 @@ describe("the Deployment surface is desktop-only", () => {
() => gateway.status(),
() => gateway.start(),
() => gateway.stop(),
() => gateway.saveExposureSettings({ mode: "localOnly", port: 0, trustedProxies: [] }),
() => gateway.previewExposure({ mode: "localOnly", port: 0, trustedProxies: [] }),
() =>
gateway.saveExposureSettings({
mode: "localOnly",
autoStart: false,
port: 0,
trustedProxies: [],
}),
() =>
gateway.previewExposure({
mode: "localOnly",
autoStart: false,
port: 0,
trustedProxies: [],
}),
]) {
const error: Partial<GatewayError> = { code: "UNSUPPORTED_ON_WEB" };
await expect(call()).rejects.toMatchObject(error);

View File

@ -56,17 +56,29 @@ export interface DeploymentVm {
warnings: DiagnosticWarning[];
/** Why the draft is rejected, as told by the backend. Actionable, not decorative. */
validationError: string | null;
/** Last save/start/stop failure. */
/** Last save/start/stop/auto-start-toggle failure. */
actionError: string | null;
/** True while a start/stop is in flight. */
busy: boolean;
/** True while the auto-start toggle's own save is in flight (#89). */
autoStartBusy: boolean;
setMode: (mode: ServerExposureMode) => void;
setPublicOrigin: (origin: string) => void;
setLanBindAddress: (address: string) => void;
setTrustedProxies: (raw: string) => void;
setPort: (port: number) => void;
/**
* Persists `autoStart` immediately (ticket #89) — never calls `start()`.
* Unlike the other setters, this does not update the draft optimistically:
* the local `settings.autoStart` only flips once the save has actually
* succeeded, so a rejected save (the same validations as `start`/`save`
* apply — e.g. a remote mode still missing its public origin or LAN/proxy)
* leaves the checkbox showing the persisted value, not a lie.
*/
setAutoStart: (enabled: boolean) => Promise<void>;
/** Persists the draft, then starts the server so what runs is what is shown. */
start: () => Promise<void>;
/** Stops the running server. Never touches the persisted `autoStart` flag. */
stop: () => Promise<void>;
}
@ -82,6 +94,7 @@ export function useDeployment(): DeploymentVm {
const [validationError, setValidationError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [autoStartBusy, setAutoStartBusy] = useState(false);
// Guards against a stale in-flight preview overwriting a newer one.
const previewSeq = useRef(0);
@ -112,6 +125,7 @@ export function useDeployment(): DeploymentVm {
try {
const preview = await desktopServer.previewExposure({
mode: "localOnly",
autoStart: false,
port: 0,
trustedProxies: [],
});
@ -204,6 +218,29 @@ export function useDeployment(): DeploymentVm {
);
const setPort = useCallback((port: number) => patch({ port }), [patch]);
const setAutoStart = useCallback(
async (enabled: boolean) => {
if (!settings) return;
const next = { ...settings, autoStart: enabled };
setAutoStartBusy(true);
setActionError(null);
try {
// Persist only — never `start()`. Auto-start is applied at the next
// app launch, not now.
await desktopServer.saveExposureSettings(next);
// Update the draft only after the save actually succeeded, so a
// rejected save (e.g. a remote mode still missing its public origin)
// never shows a checkbox state that was not actually persisted.
setSettings(next);
} catch (e) {
setActionError(messageOf(e));
} finally {
setAutoStartBusy(false);
}
},
[desktopServer, settings],
);
const start = useCallback(async () => {
if (!settings) return;
setBusy(true);
@ -242,11 +279,13 @@ export function useDeployment(): DeploymentVm {
validationError,
actionError,
busy,
autoStartBusy,
setMode,
setPublicOrigin,
setLanBindAddress,
setTrustedProxies,
setPort,
setAutoStart,
start,
stop,
};