Skip to main content

stygian_browser/
config.rs

1//! Browser configuration and options
2//!
3//! All configuration can be overridden via environment variables at runtime.
4//! See individual fields for the corresponding `STYGIAN_*` variable names.
5//!
6//! ## Configuration priority
7//!
8//! Programmatic (builder) > environment variables > JSON file > compiled-in defaults.
9//!
10//! Use [`BrowserConfig::from_json_file`] or [`BrowserConfig::from_json_str`] to
11//! load a base configuration from disk, then override individual settings via
12//! the builder or environment variables.
13
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::time::Duration;
17
18use crate::cdp_protection::CdpFixMode;
19
20#[cfg(feature = "stealth")]
21use crate::webrtc::WebRtcConfig;
22
23// ─── StealthLevel ─────────────────────────────────────────────────────────────
24
25/// Anti-detection intensity level.
26///
27/// Higher levels apply more fingerprint spoofing and behavioral mimicry at the
28/// cost of additional CPU/memory overhead.
29///
30/// # Example
31///
32/// ```
33/// use stygian_browser::config::StealthLevel;
34/// let level = StealthLevel::Advanced;
35/// assert!(level.is_active());
36/// ```
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(rename_all = "lowercase")]
39pub enum StealthLevel {
40    /// No anti-detection applied. Useful for trusted, internal targets.
41    None,
42    /// Core protections only: `navigator.webdriver` removal and CDP leak fix.
43    Basic,
44    /// Full suite: fingerprint injection, human behavior, WebRTC spoofing.
45    #[default]
46    Advanced,
47}
48
49impl StealthLevel {
50    /// Returns `true` for any level other than [`StealthLevel::None`].
51    #[must_use]
52    pub fn is_active(self) -> bool {
53        self != Self::None
54    }
55
56    /// Parse `source_url` from `STYGIAN_SOURCE_URL` (`0` disables).
57    pub fn from_env() -> Self {
58        match std::env::var("STYGIAN_STEALTH_LEVEL")
59            .unwrap_or_default()
60            .to_lowercase()
61            .as_str()
62        {
63            "none" => Self::None,
64            "basic" => Self::Basic,
65            _ => Self::Advanced,
66        }
67    }
68}
69
70// ─── PoolConfig ───────────────────────────────────────────────────────────────
71
72/// Browser pool sizing and lifecycle settings.
73///
74/// # Example
75///
76/// ```
77/// use stygian_browser::config::PoolConfig;
78/// let cfg = PoolConfig::default();
79/// assert_eq!(cfg.min_size, 2);
80/// assert_eq!(cfg.max_size, 10);
81/// ```
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PoolConfig {
84    /// Minimum warm instances kept ready at all times.
85    ///
86    /// Env: `STYGIAN_POOL_MIN` (default: `2`)
87    pub min_size: usize,
88
89    /// Maximum concurrent browser instances.
90    ///
91    /// Env: `STYGIAN_POOL_MAX` (default: `10`)
92    pub max_size: usize,
93
94    /// How long an idle browser is kept before eviction.
95    ///
96    /// Env: `STYGIAN_POOL_IDLE_SECS` (default: `300`)
97    #[serde(with = "duration_secs")]
98    pub idle_timeout: Duration,
99
100    /// Maximum time to wait for a pool slot before returning
101    /// [`PoolExhausted`][crate::error::BrowserError::PoolExhausted].
102    ///
103    /// Env: `STYGIAN_POOL_ACQUIRE_SECS` (default: `5`)
104    #[serde(with = "duration_secs")]
105    pub acquire_timeout: Duration,
106}
107
108impl Default for PoolConfig {
109    fn default() -> Self {
110        Self {
111            min_size: env_usize("STYGIAN_POOL_MIN", 2),
112            max_size: env_usize("STYGIAN_POOL_MAX", 10),
113            idle_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_IDLE_SECS", 300)),
114            acquire_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_ACQUIRE_SECS", 5)),
115        }
116    }
117}
118
119// ─── BrowserConfig ────────────────────────────────────────────────────────────
120
121/// Top-level configuration for a browser session.
122///
123/// # Example
124///
125/// ```
126/// use stygian_browser::BrowserConfig;
127///
128/// let config = BrowserConfig::builder()
129///     .headless(true)
130///     .window_size(1920, 1080)
131///     .build();
132///
133/// assert!(config.headless);
134/// ```
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct BrowserConfig {
137    /// Path to the Chrome/Chromium executable.
138    ///
139    /// Env: `STYGIAN_CHROME_PATH`
140    pub chrome_path: Option<PathBuf>,
141
142    /// Extra Chrome launch arguments appended after the defaults.
143    pub args: Vec<String>,
144
145    /// Run in headless mode (no visible window).
146    ///
147    /// Env: `STYGIAN_HEADLESS` (`true`/`false`, default: `true`)
148    pub headless: bool,
149
150    /// Persistent user profile directory. `None` = temporary profile.
151    pub user_data_dir: Option<PathBuf>,
152
153    /// Browser window size in pixels (width, height).
154    pub window_size: Option<(u32, u32)>,
155
156    /// Attach `DevTools` on launch (useful for debugging, disable in production).
157    pub devtools: bool,
158
159    /// HTTP/SOCKS proxy URL, e.g. `http://user:pass@host:port`.
160    pub proxy: Option<String>,
161
162    /// Comma-separated list of hosts that bypass the proxy.
163    ///
164    /// Env: `STYGIAN_PROXY_BYPASS` (e.g. `"<local>,localhost,127.0.0.1"`)
165    pub proxy_bypass_list: Option<String>,
166
167    /// WebRTC IP-leak prevention and geolocation consistency settings.
168    ///
169    /// Only active when the `stealth` feature is enabled.
170    #[cfg(feature = "stealth")]
171    pub webrtc: WebRtcConfig,
172
173    /// Anti-detection intensity level.
174    pub stealth_level: StealthLevel,
175
176    /// CDP Runtime.enable leak-mitigation mode.
177    ///
178    /// Env: `STYGIAN_CDP_FIX_MODE` (`add_binding`/`isolated_world`/`enable_disable`/`none`)
179    pub cdp_fix_mode: CdpFixMode,
180
181    /// Source URL injected into `Function.prototype.toString` patches, or
182    /// `None` to use the default (`"app.js"`).
183    ///
184    /// Set to `"0"` (as a string) to disable sourceURL patching entirely.
185    ///
186    /// Env: `STYGIAN_SOURCE_URL`
187    pub source_url: Option<String>,
188
189    /// Browser pool settings.
190    pub pool: PoolConfig,
191
192    /// Browser launch timeout.
193    ///
194    /// Env: `STYGIAN_LAUNCH_TIMEOUT_SECS` (default: `10`)
195    #[serde(with = "duration_secs")]
196    pub launch_timeout: Duration,
197
198    /// Per-operation CDP timeout.
199    ///
200    /// Env: `STYGIAN_CDP_TIMEOUT_SECS` (default: `30`)
201    #[serde(with = "duration_secs")]
202    pub cdp_timeout: Duration,
203}
204
205impl Default for BrowserConfig {
206    fn default() -> Self {
207        Self {
208            chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
209            args: vec![],
210            headless: env_bool("STYGIAN_HEADLESS", true),
211            user_data_dir: None,
212            window_size: Some((1920, 1080)),
213            devtools: false,
214            proxy: std::env::var("STYGIAN_PROXY").ok(),
215            proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
216            #[cfg(feature = "stealth")]
217            webrtc: WebRtcConfig::default(),
218            stealth_level: StealthLevel::from_env(),
219            cdp_fix_mode: CdpFixMode::from_env(),
220            source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
221            pool: PoolConfig::default(),
222            launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
223            cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
224        }
225    }
226}
227
228impl BrowserConfig {
229    /// Create a configuration builder with defaults pre-populated.
230    pub fn builder() -> BrowserConfigBuilder {
231        BrowserConfigBuilder {
232            config: Self::default(),
233        }
234    }
235
236    /// Collect the effective Chrome launch arguments.
237    ///
238    /// Returns the anti-detection baseline args merged with any user-supplied
239    /// extras from [`BrowserConfig::args`].
240    pub fn effective_args(&self) -> Vec<String> {
241        let mut args = vec![
242            "--disable-blink-features=AutomationControlled".to_string(),
243            "--disable-dev-shm-usage".to_string(),
244            "--no-sandbox".to_string(),
245            "--disable-infobars".to_string(),
246            "--disable-background-timer-throttling".to_string(),
247            "--disable-backgrounding-occluded-windows".to_string(),
248            "--disable-renderer-backgrounding".to_string(),
249        ];
250
251        if let Some(proxy) = &self.proxy {
252            args.push(format!("--proxy-server={proxy}"));
253        }
254
255        if let Some(bypass) = &self.proxy_bypass_list {
256            args.push(format!("--proxy-bypass-list={bypass}"));
257        }
258
259        #[cfg(feature = "stealth")]
260        args.extend(self.webrtc.chrome_args());
261
262        if let Some((w, h)) = self.window_size {
263            args.push(format!("--window-size={w},{h}"));
264        }
265
266        args.extend_from_slice(&self.args);
267        args
268    }
269
270    /// Validate the configuration, returning a list of human-readable errors.
271    ///
272    /// Returns `Ok(())` when valid, or `Err(errors)` with a non-empty list.
273    ///
274    /// # Example
275    ///
276    /// ```
277    /// use stygian_browser::BrowserConfig;
278    /// use stygian_browser::config::PoolConfig;
279    /// use std::time::Duration;
280    ///
281    /// let mut cfg = BrowserConfig::default();
282    /// cfg.pool.min_size = 0;
283    /// cfg.pool.max_size = 0; // invalid: max must be >= 1
284    /// let errors = cfg.validate().unwrap_err();
285    /// assert!(!errors.is_empty());
286    /// ```
287    pub fn validate(&self) -> Result<(), Vec<String>> {
288        let mut errors: Vec<String> = Vec::new();
289
290        if self.pool.min_size > self.pool.max_size {
291            errors.push(format!(
292                "pool.min_size ({}) must be <= pool.max_size ({})",
293                self.pool.min_size, self.pool.max_size
294            ));
295        }
296        if self.pool.max_size == 0 {
297            errors.push("pool.max_size must be >= 1".to_string());
298        }
299        if self.launch_timeout.is_zero() {
300            errors.push("launch_timeout must be positive".to_string());
301        }
302        if self.cdp_timeout.is_zero() {
303            errors.push("cdp_timeout must be positive".to_string());
304        }
305        if let Some(proxy) = &self.proxy
306            && !proxy.starts_with("http://")
307            && !proxy.starts_with("https://")
308            && !proxy.starts_with("socks4://")
309            && !proxy.starts_with("socks5://")
310        {
311            errors.push(format!(
312                "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
313            ));
314        }
315
316        if errors.is_empty() {
317            Ok(())
318        } else {
319            Err(errors)
320        }
321    }
322
323    /// Serialize this configuration to a JSON string.
324    ///
325    /// # Errors
326    ///
327    /// Returns a [`serde_json::Error`] if serialization fails (very rare).
328    ///
329    /// # Example
330    ///
331    /// ```
332    /// use stygian_browser::BrowserConfig;
333    /// let cfg = BrowserConfig::default();
334    /// let json = cfg.to_json().unwrap();
335    /// assert!(json.contains("headless"));
336    /// ```
337    pub fn to_json(&self) -> Result<String, serde_json::Error> {
338        serde_json::to_string_pretty(self)
339    }
340
341    /// Deserialize a [`BrowserConfig`] from a JSON string.
342    ///
343    /// Environment variable overrides will NOT be re-applied — the JSON values
344    /// are used verbatim.  Chain with builder methods to override individual
345    /// fields after loading.
346    ///
347    /// # Errors
348    ///
349    /// Returns a [`serde_json::Error`] if the input is invalid JSON or has
350    /// missing required fields.
351    ///
352    /// # Example
353    ///
354    /// ```
355    /// use stygian_browser::BrowserConfig;
356    /// let cfg = BrowserConfig::default();
357    /// let json = cfg.to_json().unwrap();
358    /// let back = BrowserConfig::from_json_str(&json).unwrap();
359    /// assert_eq!(back.headless, cfg.headless);
360    /// ```
361    pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
362        serde_json::from_str(s)
363    }
364
365    /// Load a [`BrowserConfig`] from a JSON file on disk.
366    ///
367    /// # Errors
368    ///
369    /// Returns a [`crate::error::BrowserError::ConfigError`] wrapping any I/O
370    /// or parse error.
371    ///
372    /// # Example
373    ///
374    /// ```no_run
375    /// use stygian_browser::BrowserConfig;
376    /// let cfg = BrowserConfig::from_json_file("/etc/stygian/config.json").unwrap();
377    /// ```
378    pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
379        use crate::error::BrowserError;
380        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
381            BrowserError::ConfigError(format!(
382                "cannot read config file {}: {e}",
383                path.as_ref().display()
384            ))
385        })?;
386        serde_json::from_str(&content).map_err(|e| {
387            BrowserError::ConfigError(format!(
388                "invalid JSON in config file {}: {e}",
389                path.as_ref().display()
390            ))
391        })
392    }
393}
394
395// ─── Builder ──────────────────────────────────────────────────────────────────
396
397/// Fluent builder for [`BrowserConfig`].
398pub struct BrowserConfigBuilder {
399    config: BrowserConfig,
400}
401
402impl BrowserConfigBuilder {
403    /// Set path to the Chrome executable.
404    #[must_use]
405    pub fn chrome_path(mut self, path: PathBuf) -> Self {
406        self.config.chrome_path = Some(path);
407        self
408    }
409
410    /// Set headless mode.
411    #[must_use]
412    pub const fn headless(mut self, headless: bool) -> Self {
413        self.config.headless = headless;
414        self
415    }
416
417    /// Set browser viewport / window size.
418    #[must_use]
419    pub const fn window_size(mut self, width: u32, height: u32) -> Self {
420        self.config.window_size = Some((width, height));
421        self
422    }
423
424    /// Enable or disable `DevTools` attachment.
425    #[must_use]
426    pub const fn devtools(mut self, enabled: bool) -> Self {
427        self.config.devtools = enabled;
428        self
429    }
430
431    /// Set proxy URL.
432    #[must_use]
433    pub fn proxy(mut self, proxy: String) -> Self {
434        self.config.proxy = Some(proxy);
435        self
436    }
437
438    /// Set a comma-separated proxy bypass list.
439    ///
440    /// # Example
441    /// ```
442    /// use stygian_browser::BrowserConfig;
443    /// let cfg = BrowserConfig::builder()
444    ///     .proxy("http://proxy:8080".to_string())
445    ///     .proxy_bypass_list("<local>,localhost".to_string())
446    ///     .build();
447    /// assert!(cfg.effective_args().iter().any(|a| a.contains("proxy-bypass")));
448    /// ```
449    #[must_use]
450    pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
451        self.config.proxy_bypass_list = Some(bypass);
452        self
453    }
454
455    /// Set WebRTC IP-leak prevention config.
456    ///
457    /// # Example
458    /// ```
459    /// use stygian_browser::BrowserConfig;
460    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
461    /// let cfg = BrowserConfig::builder()
462    ///     .webrtc(WebRtcConfig { policy: WebRtcPolicy::BlockAll, ..Default::default() })
463    ///     .build();
464    /// assert!(cfg.effective_args().iter().any(|a| a.contains("disable_non_proxied")));
465    /// ```
466    #[cfg(feature = "stealth")]
467    #[must_use]
468    pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
469        self.config.webrtc = webrtc;
470        self
471    }
472
473    /// Append a custom Chrome argument.
474    #[must_use]
475    pub fn arg(mut self, arg: String) -> Self {
476        self.config.args.push(arg);
477        self
478    }
479
480    /// Set the stealth level.
481    #[must_use]
482    pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
483        self.config.stealth_level = level;
484        self
485    }
486
487    /// Set the CDP leak-mitigation mode.
488    ///
489    /// # Example
490    ///
491    /// ```
492    /// use stygian_browser::BrowserConfig;
493    /// use stygian_browser::cdp_protection::CdpFixMode;
494    /// let cfg = BrowserConfig::builder()
495    ///     .cdp_fix_mode(CdpFixMode::IsolatedWorld)
496    ///     .build();
497    /// assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
498    /// ```
499    #[must_use]
500    pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
501        self.config.cdp_fix_mode = mode;
502        self
503    }
504
505    /// Override the `sourceURL` injected into CDP scripts, or pass `None` to
506    /// disable sourceURL patching.
507    ///
508    /// # Example
509    ///
510    /// ```
511    /// use stygian_browser::BrowserConfig;
512    /// let cfg = BrowserConfig::builder()
513    ///     .source_url(Some("main.js".to_string()))
514    ///     .build();
515    /// assert_eq!(cfg.source_url.as_deref(), Some("main.js"));
516    /// ```
517    #[must_use]
518    pub fn source_url(mut self, url: Option<String>) -> Self {
519        self.config.source_url = url;
520        self
521    }
522
523    /// Override pool settings.
524    #[must_use]
525    pub const fn pool(mut self, pool: PoolConfig) -> Self {
526        self.config.pool = pool;
527        self
528    }
529
530    /// Build the final [`BrowserConfig`].
531    pub fn build(self) -> BrowserConfig {
532        self.config
533    }
534}
535
536// ─── Serde helpers ────────────────────────────────────────────────────────────
537
538/// Serialize/deserialize `Duration` as integer seconds.
539mod duration_secs {
540    use serde::{Deserialize, Deserializer, Serialize, Serializer};
541    use std::time::Duration;
542
543    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
544        d.as_secs().serialize(s)
545    }
546
547    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
548        Ok(Duration::from_secs(u64::deserialize(d)?))
549    }
550}
551
552// ─── Env helpers (private) ────────────────────────────────────────────────────
553
554fn env_bool(key: &str, default: bool) -> bool {
555    std::env::var(key)
556        .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
557        .unwrap_or(default)
558}
559
560fn env_u64(key: &str, default: u64) -> u64 {
561    std::env::var(key)
562        .ok()
563        .and_then(|v| v.parse().ok())
564        .unwrap_or(default)
565}
566
567fn env_usize(key: &str, default: usize) -> usize {
568    std::env::var(key)
569        .ok()
570        .and_then(|v| v.parse().ok())
571        .unwrap_or(default)
572}
573
574// ─── Tests ────────────────────────────────────────────────────────────────────
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn default_config_is_headless() {
582        let cfg = BrowserConfig::default();
583        assert!(cfg.headless);
584    }
585
586    #[test]
587    fn builder_roundtrip() {
588        let cfg = BrowserConfig::builder()
589            .headless(false)
590            .window_size(1280, 720)
591            .stealth_level(StealthLevel::Basic)
592            .build();
593
594        assert!(!cfg.headless);
595        assert_eq!(cfg.window_size, Some((1280, 720)));
596        assert_eq!(cfg.stealth_level, StealthLevel::Basic);
597    }
598
599    #[test]
600    fn effective_args_include_anti_detection_flag() {
601        let cfg = BrowserConfig::default();
602        let args = cfg.effective_args();
603        assert!(args.iter().any(|a| a.contains("AutomationControlled")));
604    }
605
606    #[test]
607    fn pool_config_defaults() {
608        let p = PoolConfig::default();
609        assert_eq!(p.min_size, 2);
610        assert_eq!(p.max_size, 10);
611    }
612
613    #[test]
614    fn stealth_level_none_not_active() {
615        assert!(!StealthLevel::None.is_active());
616        assert!(StealthLevel::Basic.is_active());
617        assert!(StealthLevel::Advanced.is_active());
618    }
619
620    #[test]
621    fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
622        let cfg = BrowserConfig::default();
623        let json = serde_json::to_string(&cfg)?;
624        let back: BrowserConfig = serde_json::from_str(&json)?;
625        assert_eq!(back.headless, cfg.headless);
626        assert_eq!(back.stealth_level, cfg.stealth_level);
627        Ok(())
628    }
629
630    #[test]
631    fn validate_default_config_is_valid() {
632        let cfg = BrowserConfig::default();
633        assert!(cfg.validate().is_ok(), "default config must be valid");
634    }
635
636    #[test]
637    fn validate_detects_pool_size_inversion() {
638        let cfg = BrowserConfig {
639            pool: PoolConfig {
640                min_size: 10,
641                max_size: 5,
642                ..PoolConfig::default()
643            },
644            ..BrowserConfig::default()
645        };
646        let result = cfg.validate();
647        assert!(result.is_err());
648        if let Err(errors) = result {
649            assert!(errors.iter().any(|e| e.contains("min_size")));
650        }
651    }
652
653    #[test]
654    fn validate_detects_zero_max_pool() {
655        let cfg = BrowserConfig {
656            pool: PoolConfig {
657                max_size: 0,
658                ..PoolConfig::default()
659            },
660            ..BrowserConfig::default()
661        };
662        let result = cfg.validate();
663        assert!(result.is_err());
664        if let Err(errors) = result {
665            assert!(errors.iter().any(|e| e.contains("max_size")));
666        }
667    }
668
669    #[test]
670    fn validate_detects_zero_timeouts() {
671        let cfg = BrowserConfig {
672            launch_timeout: std::time::Duration::ZERO,
673            cdp_timeout: std::time::Duration::ZERO,
674            ..BrowserConfig::default()
675        };
676        let result = cfg.validate();
677        assert!(result.is_err());
678        if let Err(errors) = result {
679            assert_eq!(errors.len(), 2);
680        }
681    }
682
683    #[test]
684    fn validate_detects_bad_proxy_scheme() {
685        let cfg = BrowserConfig {
686            proxy: Some("ftp://bad.proxy:1234".to_string()),
687            ..BrowserConfig::default()
688        };
689        let result = cfg.validate();
690        assert!(result.is_err());
691        if let Err(errors) = result {
692            assert!(errors.iter().any(|e| e.contains("proxy URL")));
693        }
694    }
695
696    #[test]
697    fn validate_accepts_valid_proxy() {
698        let cfg = BrowserConfig {
699            proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
700            ..BrowserConfig::default()
701        };
702        assert!(cfg.validate().is_ok());
703    }
704
705    #[test]
706    fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
707        let cfg = BrowserConfig::builder()
708            .headless(false)
709            .stealth_level(StealthLevel::Basic)
710            .build();
711        let json = cfg.to_json()?;
712        assert!(json.contains("headless"));
713        let back = BrowserConfig::from_json_str(&json)?;
714        assert!(!back.headless);
715        assert_eq!(back.stealth_level, StealthLevel::Basic);
716        Ok(())
717    }
718
719    #[test]
720    fn from_json_str_error_on_invalid_json() {
721        let err = BrowserConfig::from_json_str("not json at all");
722        assert!(err.is_err());
723    }
724
725    #[test]
726    fn builder_cdp_fix_mode_and_source_url() {
727        use crate::cdp_protection::CdpFixMode;
728        let cfg = BrowserConfig::builder()
729            .cdp_fix_mode(CdpFixMode::IsolatedWorld)
730            .source_url(Some("stealth.js".to_string()))
731            .build();
732        assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
733        assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
734    }
735
736    #[test]
737    fn builder_source_url_none_disables_sourceurl() {
738        let cfg = BrowserConfig::builder().source_url(None).build();
739        assert!(cfg.source_url.is_none());
740    }
741
742    // ─── Env-var override tests ────────────────────────────────────────────────
743    //
744    // These tests set env vars and call BrowserConfig::default() to verify
745    // the overrides are picked up.  Tests use a per-test unique var name to
746    // prevent cross-test pollution, but the real STYGIAN_* paths are also
747    // exercised via a serial test that saves/restores the env.
748
749    #[test]
750    fn stealth_level_from_env_none() {
751        // env_bool / StealthLevel::from_env are pure functions — we test the
752        // conversion logic indirectly via a temporary override.
753        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
754            let level = StealthLevel::from_env();
755            assert_eq!(level, StealthLevel::None);
756        });
757    }
758
759    #[test]
760    fn stealth_level_from_env_basic() {
761        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
762            assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
763        });
764    }
765
766    #[test]
767    fn stealth_level_from_env_advanced_is_default() {
768        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
769            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
770        });
771    }
772
773    #[test]
774    fn stealth_level_from_env_missing_defaults_to_advanced() {
775        // When the key is absent, from_env() falls through to Advanced.
776        temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
777            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
778        });
779    }
780
781    #[test]
782    fn cdp_fix_mode_from_env_variants() {
783        use crate::cdp_protection::CdpFixMode;
784        let cases = [
785            ("add_binding", CdpFixMode::AddBinding),
786            ("isolatedworld", CdpFixMode::IsolatedWorld),
787            ("enable_disable", CdpFixMode::EnableDisable),
788            ("none", CdpFixMode::None),
789            ("unknown_value", CdpFixMode::AddBinding), // falls back to default
790        ];
791        for (val, expected) in cases {
792            temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
793                assert_eq!(
794                    CdpFixMode::from_env(),
795                    expected,
796                    "STYGIAN_CDP_FIX_MODE={val}"
797                );
798            });
799        }
800    }
801
802    #[test]
803    fn pool_config_from_env_min_max() {
804        temp_env::with_vars(
805            [
806                ("STYGIAN_POOL_MIN", Some("3")),
807                ("STYGIAN_POOL_MAX", Some("15")),
808            ],
809            || {
810                let p = PoolConfig::default();
811                assert_eq!(p.min_size, 3);
812                assert_eq!(p.max_size, 15);
813            },
814        );
815    }
816
817    #[test]
818    fn headless_from_env_false() {
819        temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
820            // env_bool parses the value via BrowserConfig::default()
821            assert!(!env_bool("STYGIAN_HEADLESS", true));
822        });
823    }
824
825    #[test]
826    fn headless_from_env_zero_means_false() {
827        temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
828            assert!(!env_bool("STYGIAN_HEADLESS", true));
829        });
830    }
831
832    #[test]
833    fn headless_from_env_no_means_false() {
834        temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
835            assert!(!env_bool("STYGIAN_HEADLESS", true));
836        });
837    }
838
839    #[test]
840    fn validate_accepts_socks4_proxy() {
841        let cfg = BrowserConfig {
842            proxy: Some("socks4://127.0.0.1:1080".to_string()),
843            ..BrowserConfig::default()
844        };
845        assert!(cfg.validate().is_ok());
846    }
847
848    #[test]
849    fn validate_multiple_errors_returned_together() {
850        let cfg = BrowserConfig {
851            pool: PoolConfig {
852                min_size: 10,
853                max_size: 5,
854                ..PoolConfig::default()
855            },
856            launch_timeout: std::time::Duration::ZERO,
857            proxy: Some("ftp://bad".to_string()),
858            ..BrowserConfig::default()
859        };
860        let result = cfg.validate();
861        assert!(result.is_err());
862        if let Err(errors) = result {
863            assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
864        }
865    }
866
867    #[test]
868    fn json_file_error_on_missing_file() {
869        let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
870        assert!(result.is_err());
871        if let Err(e) = result {
872            let err_str = e.to_string();
873            assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
874        }
875    }
876
877    #[test]
878    fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
879        use crate::cdp_protection::CdpFixMode;
880        let cfg = BrowserConfig::builder()
881            .cdp_fix_mode(CdpFixMode::EnableDisable)
882            .build();
883        let json = cfg.to_json()?;
884        let back = BrowserConfig::from_json_str(&json)?;
885        assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
886        Ok(())
887    }
888}
889
890// ─── temp_env helper (test-only) ─────────────────────────────────────────────
891//
892// Lightweight env-var scoping without an external dep.  Uses std::env +
893// cleanup to isolate side effects.
894
895#[cfg(test)]
896mod temp_env {
897    use std::env;
898    use std::ffi::OsStr;
899    use std::sync::Mutex;
900
901    // Serialise all env-var mutations so parallel tests don't race.
902    static ENV_LOCK: Mutex<()> = Mutex::new(());
903
904    /// Run `f` with the environment variable `key` set to `value` (or unset if
905    /// `None`), then restore the previous value.
906    pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
907    where
908        K: AsRef<OsStr>,
909        V: AsRef<OsStr>,
910        F: FnOnce(),
911    {
912        let _guard = ENV_LOCK
913            .lock()
914            .unwrap_or_else(std::sync::PoisonError::into_inner);
915        let key = key.as_ref();
916        let prev = env::var_os(key);
917        match value {
918            Some(v) => unsafe { env::set_var(key, v.as_ref()) },
919            None => unsafe { env::remove_var(key) },
920        }
921        f();
922        match prev {
923            Some(v) => unsafe { env::set_var(key, v) },
924            None => unsafe { env::remove_var(key) },
925        }
926    }
927
928    /// Run `f` with multiple env vars set/unset simultaneously.
929    pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
930    where
931        K: AsRef<OsStr>,
932        V: AsRef<OsStr>,
933        F: FnOnce(),
934    {
935        let _guard = ENV_LOCK
936            .lock()
937            .unwrap_or_else(std::sync::PoisonError::into_inner);
938        let pairs: Vec<_> = pairs
939            .into_iter()
940            .map(|(k, v)| {
941                let key = k.as_ref().to_os_string();
942                let prev = env::var_os(&key);
943                let new_val = v.map(|v| v.as_ref().to_os_string());
944                (key, prev, new_val)
945            })
946            .collect();
947
948        for (key, _, new_val) in &pairs {
949            match new_val {
950                Some(v) => unsafe { env::set_var(key, v) },
951                None => unsafe { env::remove_var(key) },
952            }
953        }
954
955        f();
956
957        for (key, prev, _) in &pairs {
958            match prev {
959                Some(v) => unsafe { env::set_var(key, v) },
960                None => unsafe { env::remove_var(key) },
961            }
962        }
963    }
964}