Skip to main content

leptos_browser_test/
config.rs

1use crate::{CargoLeptosMode, LeptosBrowserTestError, SiteScheme, app::LeptosTestApp};
2use rootcause::Report;
3use std::{
4    ffi::{OsStr, OsString},
5    path::PathBuf,
6    time::Duration,
7};
8use tokio_process_tools::UnixGracefulSignal;
9
10const DEFAULT_STARTUP_LOG_TAIL_LINES: usize = 200;
11const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(60 * 10);
12const DEFAULT_STARTUP_TIMEOUT_REASON: &str =
13    "default — generous bound for a cold cargo-leptos compile of server + wasm";
14const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
15const DEFAULT_GRACEFUL_SHUTDOWN_UNIX_SIGNAL: UnixGracefulSignal = UnixGracefulSignal::Interrupt;
16
17/// Configuration for a Leptos test app process.
18#[derive(Debug, Clone)]
19pub struct LeptosTestAppConfig {
20    pub(crate) app_dir: PathBuf,
21    pub(crate) app_name: String,
22    pub(crate) mode: CargoLeptosMode,
23    pub(crate) cargo_bin: Option<OsString>,
24    pub(crate) site_scheme: SiteScheme,
25    pub(crate) site_addr: Option<String>,
26    pub(crate) reload_port: Option<u16>,
27    pub(crate) startup_line: Option<String>,
28    pub(crate) startup_timeout: Duration,
29    pub(crate) startup_timeout_reason: String,
30    pub(crate) startup_log_tail_lines: usize,
31    pub(crate) graceful_shutdown_timeout: Duration,
32    pub(crate) graceful_shutdown_unix_signal: UnixGracefulSignal,
33    pub(crate) forward_logs: bool,
34    pub(crate) extra_env: Vec<(OsString, OsString)>,
35}
36
37impl LeptosTestAppConfig {
38    /// Create a config for a test app directory.
39    #[must_use]
40    pub fn new(app_dir: impl Into<PathBuf>) -> Self {
41        Self {
42            app_dir: app_dir.into(),
43            app_name: "Leptos test app".to_owned(),
44            mode: CargoLeptosMode::Serve,
45            cargo_bin: None,
46            site_scheme: SiteScheme::Http,
47            site_addr: None,
48            reload_port: None,
49            startup_line: None,
50            startup_timeout: DEFAULT_STARTUP_TIMEOUT,
51            startup_timeout_reason: DEFAULT_STARTUP_TIMEOUT_REASON.to_owned(),
52            startup_log_tail_lines: DEFAULT_STARTUP_LOG_TAIL_LINES,
53            graceful_shutdown_timeout: DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT,
54            graceful_shutdown_unix_signal: DEFAULT_GRACEFUL_SHUTDOWN_UNIX_SIGNAL,
55            forward_logs: true,
56            extra_env: Vec::new(),
57        }
58    }
59
60    /// Set a descriptive app name used in logs and errors.
61    #[must_use]
62    pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
63        self.app_name = app_name.into();
64        self
65    }
66
67    /// Select `cargo leptos serve` or `cargo leptos watch`.
68    #[must_use]
69    pub const fn with_mode(mut self, mode: CargoLeptosMode) -> Self {
70        self.mode = mode;
71        self
72    }
73
74    /// Override the cargo binary used to invoke `cargo leptos`.
75    ///
76    /// Useful for selecting a vendored toolchain or a `cargo +channel` proxy. If unset, the
77    /// `CARGO` environment variable is honored when present; otherwise the default `cargo` on
78    /// `PATH` is used.
79    #[must_use]
80    pub fn with_cargo(mut self, cargo_bin: impl Into<OsString>) -> Self {
81        self.cargo_bin = Some(cargo_bin.into());
82        self
83    }
84
85    /// Set the browser-facing URL scheme used by [`LeptosTestApp::base_url`].
86    ///
87    /// This does not configure TLS for the Leptos process; it only controls the URL returned to
88    /// browser tests and the default startup line expected in stdout.
89    #[must_use]
90    pub const fn with_site_scheme(mut self, site_scheme: SiteScheme) -> Self {
91        self.site_scheme = site_scheme;
92        self
93    }
94
95    /// Bind the Leptos app to a fixed site address such as `127.0.0.1:3000`.
96    ///
97    /// If not set, a free localhost port is selected.
98    #[must_use]
99    pub fn with_site_addr(mut self, site_addr: impl Into<String>) -> Self {
100        self.site_addr = Some(site_addr.into());
101        self
102    }
103
104    /// Use a fixed reload port.
105    ///
106    /// If not set, a free localhost port is selected.
107    #[must_use]
108    pub const fn with_reload_port(mut self, reload_port: u16) -> Self {
109        self.reload_port = Some(reload_port);
110        self
111    }
112
113    /// Override the stdout line fragment that marks the app as ready.
114    #[must_use]
115    pub fn with_startup_line(mut self, startup_line: impl Into<String>) -> Self {
116        self.startup_line = Some(startup_line.into());
117        self
118    }
119
120    /// Set the startup timeout, with a `reason` describing *why* the startup was expected to be
121    /// complete after the chosen value.
122    ///
123    /// The reason is logged at startup and embedded in
124    /// [`LeptosBrowserTestError::StartupTimedOut`](LeptosBrowserTestError::StartupTimedOut)
125    /// so a future debugger sees the rationale alongside the elapsed duration. Forcing the
126    /// argument prevents a source comment from being the only record of why a particular timeout
127    /// was chosen.
128    #[must_use]
129    pub fn with_startup_timeout(mut self, timeout: Duration, reason: impl Into<String>) -> Self {
130        self.startup_timeout = timeout;
131        self.startup_timeout_reason = reason.into();
132        self
133    }
134
135    /// Set how many recent stdout/stderr lines are retained for failure diagnostics.
136    #[must_use]
137    pub const fn with_startup_log_tail_lines(mut self, lines: usize) -> Self {
138        self.startup_log_tail_lines = lines;
139        self
140    }
141
142    /// Set the budget the managed Leptos app has to shut down gracefully on drop.
143    ///
144    /// Forwarded to `cargo leptos` through the `LEPTOS_GRACEFUL_SHUTDOWN_TIMEOUT_SECS` environment
145    /// variable. Bounds how long cargo-leptos waits for the application to finish. The `_SECS`
146    /// env-var protocol means resolution is whole seconds. Sub-second values are truncated by
147    /// [`Duration::as_secs`].
148    ///
149    /// Defaults to 10 seconds.
150    ///
151    /// An additional ~10s of slack is given to `cargo leptos` itself to shut down gracefully.
152    #[must_use]
153    pub fn with_graceful_shutdown_timeout(mut self, timeout: Duration) -> Self {
154        self.graceful_shutdown_timeout = timeout;
155        self
156    }
157
158    /// Set the Unix signal used to ask the managed Leptos app to shut down gracefully.
159    ///
160    /// Forwarded to `cargo leptos` through the `LEPTOS_GRACEFUL_SHUTDOWN_UNIX_SIGNAL` environment
161    /// variable. Ignored on Windows.
162    ///
163    /// Defaults to [`UnixGracefulSignal::Interrupt`] (SIGINT), matching the
164    /// `tokio::signal::ctrl_c()` flow typical tokio-driven apps install. Use
165    /// [`UnixGracefulSignal::Terminate`] (SIGTERM) for service-style children that handle SIGTERM.
166    #[must_use]
167    pub fn with_graceful_shutdown_unix_signal(mut self, signal: UnixGracefulSignal) -> Self {
168        self.graceful_shutdown_unix_signal = signal;
169        self
170    }
171
172    /// Add an environment variable for the `cargo leptos` process.
173    ///
174    /// Calls are last-write-wins: repeated `with_env` invocations for the same key override
175    /// earlier values when the child is spawned. `with_env` is also applied *after* the
176    /// framework env (`LEPTOS_SITE_ADDR`, `LEPTOS_RELOAD_PORT`, `RUST_BACKTRACE`), so it can
177    /// be used as an escape hatch to override those.
178    #[must_use]
179    pub fn with_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
180        self.extra_env
181            .push((key.as_ref().to_owned(), value.as_ref().to_owned()));
182        self
183    }
184
185    /// Forward each captured stdout/stderr line to the parent process's stdout/stderr.
186    ///
187    /// Defaults to `true` to keep the historical behavior. Set to `false` to silence the
188    /// child's logs while still capturing the startup tail used in failure diagnostics.
189    #[must_use]
190    pub const fn with_forward_logs(mut self, forward_logs: bool) -> Self {
191        self.forward_logs = forward_logs;
192        self
193    }
194
195    /// Start the configured Leptos test app.
196    ///
197    /// The returned [`LeptosTestApp`] terminates the `cargo leptos` process when dropped.
198    /// Drop-based termination uses `tokio_process_tools::TerminateOnDrop`, so tests must run inside
199    /// a multithreaded Tokio runtime. Use `#[tokio::test(flavor = "multi_thread")]` for Tokio
200    /// browser tests.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the app directory cannot be resolved, the process cannot be spawned, or
205    /// the expected startup line is not observed before the timeout.
206    pub async fn start(self) -> Result<LeptosTestApp, Report<LeptosBrowserTestError>> {
207        crate::app::start_configured_app(self).await
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use std::time::Duration;
214
215    use super::{
216        DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT, DEFAULT_GRACEFUL_SHUTDOWN_UNIX_SIGNAL,
217        DEFAULT_STARTUP_LOG_TAIL_LINES, DEFAULT_STARTUP_TIMEOUT, DEFAULT_STARTUP_TIMEOUT_REASON,
218        LeptosTestAppConfig,
219    };
220    use crate::{CargoLeptosMode, SiteScheme};
221    use assertr::prelude::*;
222    use tokio_process_tools::UnixGracefulSignal;
223
224    #[test]
225    fn new_uses_documented_defaults() {
226        let config = LeptosTestAppConfig::new("./test-app");
227
228        assert_that!(config.app_name).is_equal_to("Leptos test app");
229        assert_that!(config.mode).is_equal_to(CargoLeptosMode::Serve);
230        assert_that!(config.cargo_bin).is_equal_to(None);
231        assert_that!(config.site_scheme).is_equal_to(SiteScheme::Http);
232        assert_that!(config.site_addr).is_equal_to(None);
233        assert_that!(config.reload_port).is_equal_to(None);
234        assert_that!(config.startup_line).is_equal_to(None);
235        assert_that!(config.startup_timeout).is_equal_to(DEFAULT_STARTUP_TIMEOUT);
236        assert_that!(config.startup_timeout_reason).is_equal_to(DEFAULT_STARTUP_TIMEOUT_REASON);
237        assert_that!(config.startup_log_tail_lines).is_equal_to(DEFAULT_STARTUP_LOG_TAIL_LINES);
238        assert_that!(config.graceful_shutdown_timeout)
239            .is_equal_to(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT);
240        assert_that!(config.graceful_shutdown_unix_signal)
241            .is_equal_to(DEFAULT_GRACEFUL_SHUTDOWN_UNIX_SIGNAL);
242        assert_that!(config.forward_logs).is_true();
243        assert_that!(config.extra_env.is_empty()).is_true();
244    }
245
246    #[test]
247    fn setters_override_defaults() {
248        let config = LeptosTestAppConfig::new("./test-app")
249            .with_app_name("custom")
250            .with_mode(CargoLeptosMode::Watch)
251            .with_cargo("/opt/cargo")
252            .with_site_scheme(SiteScheme::Https)
253            .with_site_addr("127.0.0.1:4000")
254            .with_reload_port(4001)
255            .with_startup_line("ready")
256            .with_startup_timeout(
257                Duration::from_secs(5),
258                "tight bound for unit-style smoke test",
259            )
260            .with_startup_log_tail_lines(10)
261            .with_graceful_shutdown_timeout(Duration::from_millis(100))
262            .with_graceful_shutdown_unix_signal(UnixGracefulSignal::Terminate)
263            .with_forward_logs(false)
264            .with_env("FOO", "bar");
265
266        assert_that!(config.app_name).is_equal_to("custom");
267        assert_that!(config.mode).is_equal_to(CargoLeptosMode::Watch);
268        assert_that!(config.cargo_bin).is_equal_to(Some(std::ffi::OsString::from("/opt/cargo")));
269        assert_that!(config.site_scheme).is_equal_to(SiteScheme::Https);
270        assert_that!(config.site_addr).is_equal_to(Some("127.0.0.1:4000".to_owned()));
271        assert_that!(config.reload_port).is_equal_to(Some(4001));
272        assert_that!(config.startup_line).is_equal_to(Some("ready".to_owned()));
273        assert_that!(config.startup_timeout).is_equal_to(Duration::from_secs(5));
274        assert_that!(config.startup_timeout_reason)
275            .is_equal_to("tight bound for unit-style smoke test");
276        assert_that!(config.startup_log_tail_lines).is_equal_to(10);
277        assert_that!(config.graceful_shutdown_timeout).is_equal_to(Duration::from_millis(100));
278        assert_that!(config.graceful_shutdown_unix_signal)
279            .is_equal_to(UnixGracefulSignal::Terminate);
280        assert_that!(config.forward_logs).is_false();
281        assert_that!(config.extra_env.len()).is_equal_to(1);
282    }
283}