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:
@ -32,6 +32,9 @@ pub enum ServerExposureMode {
|
|||||||
pub struct ServerExposureSettingsDto {
|
pub struct ServerExposureSettingsDto {
|
||||||
/// Exposure mode.
|
/// Exposure mode.
|
||||||
pub mode: ServerExposureMode,
|
pub mode: ServerExposureMode,
|
||||||
|
/// Whether the embedded server should start automatically when IdeA boots.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_start: bool,
|
||||||
/// TCP port to bind. `0` asks the OS for an ephemeral port.
|
/// TCP port to bind. `0` asks the OS for an ephemeral port.
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Public HTTPS origin used by reverse-proxy modes.
|
/// Public HTTPS origin used by reverse-proxy modes.
|
||||||
@ -267,7 +270,7 @@ impl EmbeddedServerController {
|
|||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
let err = ErrorDto {
|
let err = ErrorDto {
|
||||||
code: "PROCESS".to_owned(),
|
code: start_error_code(&message).to_owned(),
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
self.mark_failed(err.clone());
|
self.mark_failed(err.clone());
|
||||||
@ -276,6 +279,28 @@ impl EmbeddedServerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts the embedded server at application boot when persisted settings
|
||||||
|
/// opt into auto-start.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] if persisted settings are invalid or start fails.
|
||||||
|
pub async fn auto_start_if_enabled(
|
||||||
|
&self,
|
||||||
|
core: Arc<BackendCore>,
|
||||||
|
) -> Result<Option<EmbeddedServerStatusDto>, ErrorDto> {
|
||||||
|
let settings = match self.store.read() {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(err) => {
|
||||||
|
self.mark_failed(err.clone());
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !settings.auto_start {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
self.start(core).await.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a new ephemeral pairing code on the running embedded server.
|
/// Generates a new ephemeral pairing code on the running embedded server.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -351,6 +376,7 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
|
|||||||
fn default_settings() -> ServerExposureSettingsDto {
|
fn default_settings() -> ServerExposureSettingsDto {
|
||||||
ServerExposureSettingsDto {
|
ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::LocalOnly,
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
public_origin: None,
|
public_origin: None,
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -358,6 +384,21 @@ fn default_settings() -> ServerExposureSettingsDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_error_code(message: &str) -> &'static str {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
if lower.contains("address already in use")
|
||||||
|
|| lower.contains("only one usage of each socket address")
|
||||||
|
|| lower.contains("addrinuse")
|
||||||
|
|| lower.contains("os error 98")
|
||||||
|
|| lower.contains("os error 48")
|
||||||
|
|| lower.contains("os error 10048")
|
||||||
|
{
|
||||||
|
"PORT_IN_USE"
|
||||||
|
} else {
|
||||||
|
"PROCESS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
|
||||||
match settings.mode {
|
match settings.mode {
|
||||||
ServerExposureMode::LocalOnly => {}
|
ServerExposureMode::LocalOnly => {}
|
||||||
@ -621,6 +662,10 @@ mod tests {
|
|||||||
web_root
|
web_root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn loopback_bind_available() -> bool {
|
||||||
|
std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
|
||||||
let explicit = tmp_app_data().join("explicit-web-root");
|
let explicit = tmp_app_data().join("explicit-web-root");
|
||||||
@ -655,6 +700,7 @@ mod tests {
|
|||||||
fn non_loopback_remote_requires_trusted_proxy() {
|
fn non_loopback_remote_requires_trusted_proxy() {
|
||||||
let settings = ServerExposureSettingsDto {
|
let settings = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||||
|
auto_start: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
public_origin: Some("https://idea.example.com".to_owned()),
|
public_origin: Some("https://idea.example.com".to_owned()),
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -671,6 +717,7 @@ mod tests {
|
|||||||
fn remote_requires_https_public_origin() {
|
fn remote_requires_https_public_origin() {
|
||||||
let settings = ServerExposureSettingsDto {
|
let settings = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::RemoteProxyLocal,
|
mode: ServerExposureMode::RemoteProxyLocal,
|
||||||
|
auto_start: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
public_origin: Some("http://idea.example.com".to_owned()),
|
public_origin: Some("http://idea.example.com".to_owned()),
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -687,6 +734,7 @@ mod tests {
|
|||||||
fn local_only_derives_loopback_config() {
|
fn local_only_derives_loopback_config() {
|
||||||
let settings = ServerExposureSettingsDto {
|
let settings = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::LocalOnly,
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: false,
|
||||||
port: 0,
|
port: 0,
|
||||||
public_origin: Some("https://ignored.example".to_owned()),
|
public_origin: Some("https://ignored.example".to_owned()),
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -705,6 +753,7 @@ mod tests {
|
|||||||
fn remote_proxy_local_accepts_loopback_ephemeral_port() {
|
fn remote_proxy_local_accepts_loopback_ephemeral_port() {
|
||||||
let settings = ServerExposureSettingsDto {
|
let settings = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::RemoteProxyLocal,
|
mode: ServerExposureMode::RemoteProxyLocal,
|
||||||
|
auto_start: false,
|
||||||
port: 0,
|
port: 0,
|
||||||
public_origin: Some("https://idea.example.com".to_owned()),
|
public_origin: Some("https://idea.example.com".to_owned()),
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -724,6 +773,7 @@ mod tests {
|
|||||||
let store = FsServerExposureSettingsStore::new(tmp_app_data());
|
let store = FsServerExposureSettingsStore::new(tmp_app_data());
|
||||||
let settings = ServerExposureSettingsDto {
|
let settings = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
mode: ServerExposureMode::RemoteProxyOtherMachine,
|
||||||
|
auto_start: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
public_origin: Some("https://idea.example.com".to_owned()),
|
public_origin: Some("https://idea.example.com".to_owned()),
|
||||||
trusted_proxies: vec!["192.0.2.22".to_owned()],
|
trusted_proxies: vec!["192.0.2.22".to_owned()],
|
||||||
@ -752,6 +802,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn start_is_idempotent_and_stop_stops_running_server() {
|
async fn start_is_idempotent_and_stop_stops_running_server() {
|
||||||
|
if !loopback_bind_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let app_data = tmp_app_data();
|
let app_data = tmp_app_data();
|
||||||
let web_root = tmp_web_root();
|
let web_root = tmp_web_root();
|
||||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||||
@ -759,6 +812,7 @@ mod tests {
|
|||||||
controller
|
controller
|
||||||
.save_settings(ServerExposureSettingsDto {
|
.save_settings(ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::LocalOnly,
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: false,
|
||||||
port: 0,
|
port: 0,
|
||||||
public_origin: None,
|
public_origin: None,
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -800,6 +854,7 @@ mod tests {
|
|||||||
let controller = EmbeddedServerController::new(app_data.clone());
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
let bad = ServerExposureSettingsDto {
|
let bad = ServerExposureSettingsDto {
|
||||||
mode: ServerExposureMode::RemoteProxyLocal,
|
mode: ServerExposureMode::RemoteProxyLocal,
|
||||||
|
auto_start: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
public_origin: Some("http://idea.example.com".to_owned()),
|
public_origin: Some("http://idea.example.com".to_owned()),
|
||||||
trusted_proxies: Vec::new(),
|
trusted_proxies: Vec::new(),
|
||||||
@ -816,4 +871,166 @@ mod tests {
|
|||||||
assert_eq!(err.code, "INVALID");
|
assert_eq!(err.code, "INVALID");
|
||||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_auto_start_default_to_disabled() {
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let path = app_data.join("deployment").join("server-exposure.json");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"mode":"localOnly","port":17373,"publicOrigin":null,"trustedProxies":[],"lanBindAddress":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = FsServerExposureSettingsStore::new(app_data);
|
||||||
|
|
||||||
|
let settings = store.read().unwrap();
|
||||||
|
|
||||||
|
assert!(!settings.auto_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_start_disabled_does_not_start() {
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
|
controller
|
||||||
|
.save_settings(ServerExposureSettingsDto {
|
||||||
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: false,
|
||||||
|
port: 0,
|
||||||
|
public_origin: None,
|
||||||
|
trusted_proxies: Vec::new(),
|
||||||
|
lan_bind_address: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let core = Arc::new(BackendCore::build(app_data));
|
||||||
|
|
||||||
|
let status = controller.auto_start_if_enabled(core).await.unwrap();
|
||||||
|
|
||||||
|
assert!(status.is_none());
|
||||||
|
assert!(matches!(
|
||||||
|
controller.status().state,
|
||||||
|
EmbeddedServerStatusStateDto::Stopped
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_start_enabled_starts_server() {
|
||||||
|
if !loopback_bind_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let web_root = tmp_web_root();
|
||||||
|
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||||
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
|
controller
|
||||||
|
.save_settings(ServerExposureSettingsDto {
|
||||||
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: true,
|
||||||
|
port: 0,
|
||||||
|
public_origin: None,
|
||||||
|
trusted_proxies: Vec::new(),
|
||||||
|
lan_bind_address: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let core = Arc::new(BackendCore::build(app_data));
|
||||||
|
|
||||||
|
let status = controller
|
||||||
|
.auto_start_if_enabled(core)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("auto-start should start");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
status.state,
|
||||||
|
EmbeddedServerStatusStateDto::Running
|
||||||
|
));
|
||||||
|
assert!(status.local_url.is_some());
|
||||||
|
controller.stop().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_start_enabled_with_invalid_config_marks_failed() {
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
|
let path = app_data.join("deployment").join("server-exposure.json");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"mode":"remoteProxyLocal","autoStart":true,"port":17373,"publicOrigin":"http://idea.example.com","trustedProxies":[],"lanBindAddress":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let core = Arc::new(BackendCore::build(app_data));
|
||||||
|
|
||||||
|
let err = controller.auto_start_if_enabled(core).await.unwrap_err();
|
||||||
|
let status = controller.status();
|
||||||
|
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||||
|
assert_eq!(
|
||||||
|
status.error.as_ref().map(|err| err.code.as_str()),
|
||||||
|
Some("INVALID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_does_not_modify_auto_start_setting() {
|
||||||
|
if !loopback_bind_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let web_root = tmp_web_root();
|
||||||
|
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||||
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
|
controller
|
||||||
|
.save_settings(ServerExposureSettingsDto {
|
||||||
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: true,
|
||||||
|
port: 0,
|
||||||
|
public_origin: None,
|
||||||
|
trusted_proxies: Vec::new(),
|
||||||
|
lan_bind_address: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let core = Arc::new(BackendCore::build(app_data));
|
||||||
|
controller.start(core).await.unwrap();
|
||||||
|
|
||||||
|
controller.stop().await.unwrap();
|
||||||
|
|
||||||
|
assert!(controller.get_settings().unwrap().auto_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_port_collision_returns_actionable_error_code() {
|
||||||
|
if !loopback_bind_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app_data = tmp_app_data();
|
||||||
|
let web_root = tmp_web_root();
|
||||||
|
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||||
|
let reserved = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let port = reserved.local_addr().unwrap().port();
|
||||||
|
let controller = EmbeddedServerController::new(app_data.clone());
|
||||||
|
controller
|
||||||
|
.save_settings(ServerExposureSettingsDto {
|
||||||
|
mode: ServerExposureMode::LocalOnly,
|
||||||
|
auto_start: false,
|
||||||
|
port,
|
||||||
|
public_origin: None,
|
||||||
|
trusted_proxies: Vec::new(),
|
||||||
|
lan_bind_address: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let core = Arc::new(BackendCore::build(app_data));
|
||||||
|
|
||||||
|
let err = controller.start(core).await.unwrap_err();
|
||||||
|
let status = controller.status();
|
||||||
|
|
||||||
|
assert_eq!(err.code, "PORT_IN_USE");
|
||||||
|
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||||
|
assert_eq!(
|
||||||
|
status.error.as_ref().map(|err| err.code.as_str()),
|
||||||
|
Some("PORT_IN_USE")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ pub mod tickets;
|
|||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
|
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
|
||||||
use domain::{
|
use domain::{
|
||||||
@ -152,7 +153,18 @@ pub fn run() {
|
|||||||
// Wire the domain event bus → Tauri events relay.
|
// Wire the domain event bus → Tauri events relay.
|
||||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
|
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||||
|
let core = app_state.core();
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = embedded_server.auto_start_if_enabled(core).await {
|
||||||
|
application::diag!(
|
||||||
|
"[embedded-server] auto-start failed: {}: {}",
|
||||||
|
err.code,
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||||
// independent of the per-view (navigation/layout) lifecycle — those
|
// independent of the per-view (navigation/layout) lifecycle — those
|
||||||
|
|||||||
@ -1410,6 +1410,7 @@ export class MockModelServerGateway implements ModelServerGateway {
|
|||||||
/** Mirror of the backend `default_settings()` (ticket #68). */
|
/** Mirror of the backend `default_settings()` (ticket #68). */
|
||||||
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
|
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
|
||||||
mode: "localOnly",
|
mode: "localOnly",
|
||||||
|
autoStart: false,
|
||||||
port: 17373,
|
port: 17373,
|
||||||
trustedProxies: [],
|
trustedProxies: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -179,6 +179,12 @@ export type ServerExposureMode =
|
|||||||
*/
|
*/
|
||||||
export interface ServerExposureSettings {
|
export interface ServerExposureSettings {
|
||||||
mode: ServerExposureMode;
|
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. */
|
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
|
||||||
port: number;
|
port: number;
|
||||||
/** Public HTTPS origin, required by both remote modes. */
|
/** Public HTTPS origin, required by both remote modes. */
|
||||||
|
|||||||
@ -210,4 +210,152 @@ describe("DeploymentSettings", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("Échec")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("Échec")).toBeTruthy());
|
||||||
expect(screen.getByRole("alert").textContent).toMatch(/already in use/);
|
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.
|
* a dead end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import type { ServerExposureMode } from "@/domain";
|
import type { ServerExposureMode } from "@/domain";
|
||||||
import { Button, Field, Input, Panel, cn } from "@/shared";
|
import { Button, Field, Input, Panel, cn } from "@/shared";
|
||||||
@ -105,6 +105,9 @@ function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string
|
|||||||
|
|
||||||
export function DeploymentSettings() {
|
export function DeploymentSettings() {
|
||||||
const vm = useDeployment();
|
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) {
|
if (!vm.ready || !vm.settings) {
|
||||||
return (
|
return (
|
||||||
@ -170,17 +173,72 @@ export function DeploymentSettings() {
|
|||||||
</p>
|
</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 && (
|
{status.state === "failed" && status.error && (
|
||||||
|
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">
|
<p role="alert" className="text-sm text-danger">
|
||||||
{status.error.message}
|
{status.error.message}
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{vm.actionError && (
|
{vm.actionError && (
|
||||||
<p role="alert" className="text-sm text-danger">
|
<p role="alert" className="text-sm text-danger">
|
||||||
{vm.actionError}
|
{vm.actionError}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@ -225,6 +283,7 @@ export function DeploymentSettings() {
|
|||||||
<Field label="Port">
|
<Field label="Port">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Input
|
<Input
|
||||||
|
ref={portInputRef}
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
value={String(settings.port)}
|
value={String(settings.port)}
|
||||||
|
|||||||
@ -42,8 +42,20 @@ describe("the Deployment surface is desktop-only", () => {
|
|||||||
() => gateway.status(),
|
() => gateway.status(),
|
||||||
() => gateway.start(),
|
() => gateway.start(),
|
||||||
() => gateway.stop(),
|
() => 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" };
|
const error: Partial<GatewayError> = { code: "UNSUPPORTED_ON_WEB" };
|
||||||
await expect(call()).rejects.toMatchObject(error);
|
await expect(call()).rejects.toMatchObject(error);
|
||||||
|
|||||||
@ -56,17 +56,29 @@ export interface DeploymentVm {
|
|||||||
warnings: DiagnosticWarning[];
|
warnings: DiagnosticWarning[];
|
||||||
/** Why the draft is rejected, as told by the backend. Actionable, not decorative. */
|
/** Why the draft is rejected, as told by the backend. Actionable, not decorative. */
|
||||||
validationError: string | null;
|
validationError: string | null;
|
||||||
/** Last save/start/stop failure. */
|
/** Last save/start/stop/auto-start-toggle failure. */
|
||||||
actionError: string | null;
|
actionError: string | null;
|
||||||
/** True while a start/stop is in flight. */
|
/** True while a start/stop is in flight. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/** True while the auto-start toggle's own save is in flight (#89). */
|
||||||
|
autoStartBusy: boolean;
|
||||||
setMode: (mode: ServerExposureMode) => void;
|
setMode: (mode: ServerExposureMode) => void;
|
||||||
setPublicOrigin: (origin: string) => void;
|
setPublicOrigin: (origin: string) => void;
|
||||||
setLanBindAddress: (address: string) => void;
|
setLanBindAddress: (address: string) => void;
|
||||||
setTrustedProxies: (raw: string) => void;
|
setTrustedProxies: (raw: string) => void;
|
||||||
setPort: (port: number) => 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. */
|
/** Persists the draft, then starts the server so what runs is what is shown. */
|
||||||
start: () => Promise<void>;
|
start: () => Promise<void>;
|
||||||
|
/** Stops the running server. Never touches the persisted `autoStart` flag. */
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +94,7 @@ export function useDeployment(): DeploymentVm {
|
|||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [autoStartBusy, setAutoStartBusy] = useState(false);
|
||||||
// Guards against a stale in-flight preview overwriting a newer one.
|
// Guards against a stale in-flight preview overwriting a newer one.
|
||||||
const previewSeq = useRef(0);
|
const previewSeq = useRef(0);
|
||||||
|
|
||||||
@ -112,6 +125,7 @@ export function useDeployment(): DeploymentVm {
|
|||||||
try {
|
try {
|
||||||
const preview = await desktopServer.previewExposure({
|
const preview = await desktopServer.previewExposure({
|
||||||
mode: "localOnly",
|
mode: "localOnly",
|
||||||
|
autoStart: false,
|
||||||
port: 0,
|
port: 0,
|
||||||
trustedProxies: [],
|
trustedProxies: [],
|
||||||
});
|
});
|
||||||
@ -204,6 +218,29 @@ export function useDeployment(): DeploymentVm {
|
|||||||
);
|
);
|
||||||
const setPort = useCallback((port: number) => patch({ port }), [patch]);
|
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 () => {
|
const start = useCallback(async () => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -242,11 +279,13 @@ export function useDeployment(): DeploymentVm {
|
|||||||
validationError,
|
validationError,
|
||||||
actionError,
|
actionError,
|
||||||
busy,
|
busy,
|
||||||
|
autoStartBusy,
|
||||||
setMode,
|
setMode,
|
||||||
setPublicOrigin,
|
setPublicOrigin,
|
||||||
setLanBindAddress,
|
setLanBindAddress,
|
||||||
setTrustedProxies,
|
setTrustedProxies,
|
||||||
setPort,
|
setPort,
|
||||||
|
setAutoStart,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user