Merge feature/ticket89-server-autostart into develop
Ajoute l'option de lancement automatique du serveur web au démarrage d'IdeA (#89) : déclenchement backend Tauri au boot selon la préférence utilisateur persistée, réglage frontend dans les paramètres de déploiement avec erreur port occupé actionnable. QA : 57 tests backend app-tauri verts (embedded_server/auto_start compris), 895/895 tests frontend, tsc propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -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: [],
|
||||
};
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user