use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::impersonate::Profile;
use crate::proxy::RotationStrategy;
use crate::wait_strategy::WaitStrategy;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub max_concurrent_render: usize,
pub max_concurrent_http: usize,
pub max_depth: Option<u32>,
pub same_host_only: bool,
pub include_subdomains: bool,
#[serde(default)]
pub target_domain: Option<String>,
#[serde(default)]
pub infra_intel: InfraIntelConfig,
#[serde(default)]
pub identity_preset: Option<u8>,
pub respect_robots_txt: bool,
#[serde(default = "default_crawl_purposes")]
pub crawl_purposes: Vec<crate::robots::Purpose>,
pub user_agent_profile: Profile,
pub chrome_path: Option<String>,
pub chrome_flags: Vec<String>,
pub block_resources: Vec<String>,
#[serde(default)]
pub reject_resource_types: Vec<RejectResourceType>,
pub wait_strategy: WaitStrategy,
pub rate_per_host_rps: Option<f64>,
pub retry_max: u32,
pub retry_backoff: Duration,
pub queue_backend: QueueBackend,
pub storage_backend: StorageBackend,
pub output: OutputConfig,
pub proxy: ProxyConfig,
pub locale: Option<String>,
pub timezone: Option<String>,
pub metrics_prometheus_port: Option<u16>,
pub hook_scripts: Vec<String>,
pub discovery_filter_regex: Option<String>,
pub follow_pages_only: bool,
pub crtsh_enabled: bool,
pub robots_paths_enabled: bool,
pub well_known_enabled: bool,
pub pwa_enabled: bool,
pub wayback_enabled: bool,
pub favicon_enabled: bool,
pub dns_enabled: bool,
pub collect_net_timings: bool,
pub collect_web_vitals: bool,
pub collect_peer_cert: bool,
pub rdap_enabled: bool,
pub cookies_enabled: bool,
#[serde(default)]
pub render_session_scope: RenderSessionScope,
pub follow_redirects: bool,
pub max_redirects: u8,
#[cfg(feature = "cdp-backend")]
#[serde(skip)]
pub actions: Option<Vec<crate::render::actions::Action>>,
#[cfg(feature = "cdp-backend")]
#[serde(skip)]
pub script_spec: Option<crate::script::ScriptSpec>,
pub profile_autodetect: bool,
pub user_agent_override: Option<String>,
pub auto_fetch_chromium: bool,
#[serde(default)]
pub action_policy: crate::policy::ActionPolicy,
#[serde(default)]
pub challenge_mode: ChallengeMode,
#[serde(default = "default_true")]
pub collect_runtime_routes: bool,
#[serde(default = "default_true")]
pub collect_network_endpoints: bool,
#[serde(default)]
pub collect_indexeddb: bool,
#[serde(default)]
pub collect_cache_storage: bool,
#[serde(default = "default_true")]
pub collect_manifest: bool,
#[serde(default = "default_true")]
pub collect_service_workers: bool,
#[serde(default = "default_max_browsers")]
pub max_browsers: usize,
#[serde(default = "default_max_pages_per_context")]
pub max_pages_per_context: usize,
#[serde(default)]
pub render_budgets: crate::scheduler::BudgetLimits,
#[serde(default = "default_session_ttl_secs")]
pub session_ttl_secs: u64,
#[serde(default = "default_true")]
pub drop_session_on_block: bool,
#[serde(default)]
pub session_scope_auto: bool,
#[cfg(feature = "cdp-backend")]
#[serde(default)]
pub motion_profile: crate::render::motion::MotionProfile,
#[serde(default)]
pub warmup: WarmupPolicy,
#[serde(default)]
pub reading_dwell: Option<ReadingDwellConfig>,
#[serde(default)]
pub http_limits: HttpLimits,
#[serde(default)]
pub content_store: ContentStoreConfig,
#[serde(default)]
pub cache_validation: CacheValidationConfig,
#[serde(default)]
pub prefetch: bool,
#[serde(default)]
pub crawl_scoring: CrawlScoringConfig,
#[serde(default)]
pub fallback_fetch: Option<FallbackFetchConfig>,
#[serde(default)]
pub external_cdp_url: Option<String>,
#[serde(default)]
pub gpu_policy: GpuPolicy,
#[serde(default)]
pub dom_capture: DomCaptureConfig,
#[serde(default)]
pub render_mode: RenderMode,
#[serde(default)]
pub browser_provider: BrowserProvider,
#[serde(default)]
pub mismatch_policy: MismatchPolicy,
#[serde(default)]
pub external_cdp_session_mode: ExternalCdpSessionMode,
#[serde(default)]
pub provider_fallback: ProviderFallbackConfig,
#[serde(default)]
pub job_max_runtime_secs: Option<u64>,
#[serde(default)]
pub result_retention_secs: Option<u64>,
#[serde(default)]
pub max_pages: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContentStoreConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub root: Option<String>,
#[serde(default)]
pub inline_legacy_columns: bool,
}
impl Default for ContentStoreConfig {
fn default() -> Self {
Self {
enabled: true,
root: None,
inline_legacy_columns: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CacheValidationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub max_age_secs: Option<u64>,
#[serde(default)]
pub modified_since: Option<u64>,
}
impl Default for CacheValidationConfig {
fn default() -> Self {
Self {
enabled: false,
max_age_secs: None,
modified_since: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrawlScoringConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default = "default_score_same_host_bonus")]
pub same_host_bonus: i32,
#[serde(default = "default_score_keyword_bonus")]
pub keyword_bonus: i32,
#[serde(default = "default_score_depth_penalty")]
pub depth_penalty: i32,
#[serde(default = "default_score_path_depth_penalty")]
pub path_depth_penalty: i32,
}
fn default_score_same_host_bonus() -> i32 {
5
}
fn default_score_keyword_bonus() -> i32 {
8
}
fn default_score_depth_penalty() -> i32 {
2
}
fn default_score_path_depth_penalty() -> i32 {
1
}
impl Default for CrawlScoringConfig {
fn default() -> Self {
Self {
enabled: false,
keywords: Vec::new(),
same_host_bonus: default_score_same_host_bonus(),
keyword_bonus: default_score_keyword_bonus(),
depth_penalty: default_score_depth_penalty(),
path_depth_penalty: default_score_path_depth_penalty(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FallbackFetchConfig {
pub command: Vec<String>,
#[serde(default = "default_fallback_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_fallback_max_output_bytes")]
pub max_output_bytes: u64,
}
fn default_fallback_timeout_ms() -> u64 {
60_000
}
fn default_fallback_max_output_bytes() -> u64 {
32 * 1024 * 1024
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GpuPolicy {
#[default]
Compat,
Stealth,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DomCaptureConfig {
#[serde(default)]
pub flatten_shadow_dom: bool,
#[serde(default)]
pub remove_overlays: bool,
#[serde(default)]
pub remove_consent_popups: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HttpLimits {
#[serde(default = "default_http_request_timeout")]
pub request_timeout: Duration,
#[serde(default = "default_max_encoded_body_bytes")]
pub max_encoded_body_bytes: Option<usize>,
#[serde(default = "default_max_decoded_body_bytes")]
pub max_decoded_body_bytes: Option<usize>,
#[serde(default = "default_max_decompression_ratio")]
pub max_decompression_ratio: usize,
#[serde(default)]
pub store_truncated_bodies: bool,
}
fn default_http_request_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_max_encoded_body_bytes() -> Option<usize> {
Some(16 * 1024 * 1024)
}
fn default_max_decoded_body_bytes() -> Option<usize> {
Some(32 * 1024 * 1024)
}
fn default_max_decompression_ratio() -> usize {
100
}
impl Default for HttpLimits {
fn default() -> Self {
Self {
request_timeout: default_http_request_timeout(),
max_encoded_body_bytes: default_max_encoded_body_bytes(),
max_decoded_body_bytes: default_max_decoded_body_bytes(),
max_decompression_ratio: default_max_decompression_ratio(),
store_truncated_bodies: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReadingDwellConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_reading_dwell_wpm")]
pub wpm: u32,
#[serde(default = "default_reading_dwell_jitter_ms")]
pub jitter_ms: u64,
#[serde(default = "default_reading_dwell_min_ms")]
pub min_ms: u64,
#[serde(default = "default_reading_dwell_max_ms")]
pub max_ms: u64,
}
fn default_reading_dwell_wpm() -> u32 {
250
}
fn default_reading_dwell_jitter_ms() -> u64 {
40
}
fn default_reading_dwell_min_ms() -> u64 {
500
}
fn default_reading_dwell_max_ms() -> u64 {
10_000
}
impl Default for ReadingDwellConfig {
fn default() -> Self {
Self {
enabled: false,
wpm: default_reading_dwell_wpm(),
jitter_ms: default_reading_dwell_jitter_ms(),
min_ms: default_reading_dwell_min_ms(),
max_ms: default_reading_dwell_max_ms(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WarmupPolicy {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_warmup_template")]
pub url_template: String,
#[serde(default = "default_warmup_dwell_ms")]
pub dwell_ms: u64,
}
fn default_warmup_template() -> String {
"{origin}".to_string()
}
fn default_warmup_dwell_ms() -> u64 {
1500
}
impl Default for WarmupPolicy {
fn default() -> Self {
Self {
enabled: false,
url_template: default_warmup_template(),
dwell_ms: default_warmup_dwell_ms(),
}
}
}
impl WarmupPolicy {
pub fn render_template(&self, target: &url::Url) -> String {
let host = target.host_str().unwrap_or("");
let mut origin = String::new();
origin.push_str(target.scheme());
origin.push_str("://");
origin.push_str(host);
if let Some(port) = target.port() {
origin.push(':');
origin.push_str(&port.to_string());
}
self.url_template
.replace("{origin}", &origin)
.replace("{host}", host)
}
}
fn default_true() -> bool {
true
}
fn default_max_browsers() -> usize {
4
}
fn default_max_pages_per_context() -> usize {
4
}
fn default_session_ttl_secs() -> u64 {
crate::identity::DEFAULT_SESSION_TTL_SECS
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutputConfig {
pub html_dir: Option<String>,
pub graph_path: Option<String>,
pub metadata_path: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot: bool,
#[serde(default)]
pub screenshot_mode: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProxyConfig {
pub proxies: Vec<String>,
pub proxy_file: Option<String>,
pub strategy: RotationStrategy,
pub sticky_per_host: bool,
pub health_check_interval: Option<Duration>,
}
pub fn normalize_reddb_uri(target: impl AsRef<str>) -> String {
let raw = target.as_ref().trim();
if raw.is_empty() {
return "file://crawlex.redb".to_string();
}
let lower = raw.to_ascii_lowercase();
if lower.starts_with("red://")
|| lower.starts_with("reds://")
|| lower.starts_with("red+wss://")
|| lower.starts_with("grpc://")
|| lower.starts_with("grpcs://")
|| lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("file://")
|| lower.starts_with("memory://")
{
raw.to_string()
} else {
format!("file://{raw}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QueueBackend {
InMemory,
Reddb { uri: String },
Sqlite { path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StorageBackend {
Memory,
Reddb { uri: String },
Sqlite { path: String },
Filesystem { root: String },
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RenderMode {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum BrowserProvider {
#[default]
Stock,
Cdp,
Auto,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MismatchPolicy {
#[default]
Adapt,
Strict,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExternalCdpSessionMode {
#[default]
Isolated,
Persistent,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderFallbackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub order: Vec<BrowserProvider>,
}
impl ProviderFallbackConfig {
pub fn is_active(&self) -> bool {
self.enabled && !self.order.is_empty()
}
pub fn chain_after(&self, primary: BrowserProvider) -> Vec<BrowserProvider> {
if !self.is_active() {
return Vec::new();
}
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for p in &self.order {
if *p == primary || *p == BrowserProvider::Auto {
continue;
}
if seen.insert(*p) {
out.push(*p);
}
}
out
}
}
impl ExternalCdpSessionMode {
pub fn as_str(self) -> &'static str {
match self {
ExternalCdpSessionMode::Isolated => "isolated",
ExternalCdpSessionMode::Persistent => "persistent",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"isolated" => Some(ExternalCdpSessionMode::Isolated),
"persistent" => Some(ExternalCdpSessionMode::Persistent),
_ => None,
}
}
pub fn is_persistent(self) -> bool {
matches!(self, ExternalCdpSessionMode::Persistent)
}
}
impl MismatchPolicy {
pub fn as_str(self) -> &'static str {
match self {
MismatchPolicy::Adapt => "adapt",
MismatchPolicy::Strict => "strict",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"adapt" => Some(MismatchPolicy::Adapt),
"strict" => Some(MismatchPolicy::Strict),
_ => None,
}
}
}
impl BrowserProvider {
pub fn as_str(self) -> &'static str {
match self {
BrowserProvider::Stock => "stock",
BrowserProvider::Cdp => "cdp",
BrowserProvider::Auto => "auto",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"stock" => Some(BrowserProvider::Stock),
"cdp" => Some(BrowserProvider::Cdp),
"auto" => Some(BrowserProvider::Auto),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RenderSessionScope {
#[default]
RegistrableDomain,
Host,
Origin,
Url,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChallengeMode {
Avoidance,
#[default]
SolverReady,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfraIntelConfig {
pub subdomains: bool,
pub dns: bool,
pub whois: bool,
pub cert: bool,
pub ct_logs: bool,
pub server_fp: bool,
pub reverse_ip: bool,
pub network_probe: bool,
}
impl Default for InfraIntelConfig {
fn default() -> Self {
Self {
subdomains: true,
dns: true,
whois: true,
cert: true,
ct_logs: true,
server_fp: true,
reverse_ip: true,
network_probe: false,
}
}
}
fn default_crawl_purposes() -> Vec<crate::robots::Purpose> {
crate::robots::Purpose::all().to_vec()
}
impl Default for Config {
fn default() -> Self {
Self {
max_concurrent_render: 0,
max_concurrent_http: 500,
max_depth: Some(5),
same_host_only: false,
include_subdomains: true,
target_domain: None,
infra_intel: InfraIntelConfig::default(),
identity_preset: None,
respect_robots_txt: true,
crawl_purposes: default_crawl_purposes(),
user_agent_profile: Profile::Chrome131Stable,
chrome_path: None,
chrome_flags: Vec::new(),
block_resources: Vec::new(),
reject_resource_types: Vec::new(),
wait_strategy: WaitStrategy::NetworkIdle { idle_ms: 500 },
rate_per_host_rps: None,
retry_max: 3,
retry_backoff: Duration::from_millis(500),
queue_backend: QueueBackend::Reddb {
uri: "file://crawlex-queue.rdb".into(),
},
storage_backend: StorageBackend::Reddb {
uri: "file://crawlex-storage.rdb".into(),
},
output: OutputConfig::default(),
proxy: ProxyConfig::default(),
locale: None,
timezone: None,
metrics_prometheus_port: None,
hook_scripts: Vec::new(),
discovery_filter_regex: None,
follow_pages_only: true,
crtsh_enabled: false,
robots_paths_enabled: true,
well_known_enabled: true,
pwa_enabled: true,
wayback_enabled: false,
favicon_enabled: true,
dns_enabled: false,
collect_net_timings: false,
collect_web_vitals: false,
collect_peer_cert: false,
rdap_enabled: false,
cookies_enabled: true,
render_session_scope: RenderSessionScope::RegistrableDomain,
follow_redirects: true,
max_redirects: 10,
#[cfg(feature = "cdp-backend")]
actions: None,
#[cfg(feature = "cdp-backend")]
script_spec: None,
profile_autodetect: true,
user_agent_override: None,
auto_fetch_chromium: true,
action_policy: crate::policy::ActionPolicy::permissive(),
challenge_mode: ChallengeMode::SolverReady,
collect_runtime_routes: true,
collect_network_endpoints: true,
collect_indexeddb: false,
collect_cache_storage: false,
collect_manifest: true,
collect_service_workers: true,
max_browsers: default_max_browsers(),
max_pages_per_context: default_max_pages_per_context(),
render_budgets: crate::scheduler::BudgetLimits::default(),
session_ttl_secs: default_session_ttl_secs(),
drop_session_on_block: true,
session_scope_auto: false,
#[cfg(feature = "cdp-backend")]
motion_profile: crate::render::motion::MotionProfile::default(),
warmup: WarmupPolicy::default(),
reading_dwell: None,
http_limits: HttpLimits::default(),
content_store: ContentStoreConfig::default(),
cache_validation: CacheValidationConfig::default(),
prefetch: false,
crawl_scoring: CrawlScoringConfig::default(),
fallback_fetch: None,
external_cdp_url: None,
gpu_policy: GpuPolicy::default(),
dom_capture: DomCaptureConfig::default(),
render_mode: RenderMode::default(),
browser_provider: BrowserProvider::default(),
mismatch_policy: MismatchPolicy::default(),
external_cdp_session_mode: ExternalCdpSessionMode::default(),
provider_fallback: ProviderFallbackConfig::default(),
job_max_runtime_secs: None,
result_retention_secs: None,
max_pages: None,
}
}
}
impl Config {
pub fn builder() -> ConfigBuilder {
ConfigBuilder::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RejectResourceType {
Image,
Media,
Font,
Stylesheet,
}
impl RejectResourceType {
pub fn url_patterns(self) -> &'static [&'static str] {
match self {
RejectResourceType::Image => &[
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.avif", "*.svg", "*.ico",
],
RejectResourceType::Media => &["*.mp3", "*.mp4", "*.webm", "*.ogg", "*.m3u8", "*.mpd"],
RejectResourceType::Font => &["*.woff", "*.woff2", "*.ttf", "*.otf", "*.eot"],
RejectResourceType::Stylesheet => &["*.css"],
}
}
pub fn as_str(self) -> &'static str {
match self {
RejectResourceType::Image => "image",
RejectResourceType::Media => "media",
RejectResourceType::Font => "font",
RejectResourceType::Stylesheet => "stylesheet",
}
}
}
impl std::str::FromStr for RejectResourceType {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"image" => Ok(RejectResourceType::Image),
"media" => Ok(RejectResourceType::Media),
"font" => Ok(RejectResourceType::Font),
"stylesheet" => Ok(RejectResourceType::Stylesheet),
other => Err(format!(
"unknown reject_resource_type `{other}`; expected one of \
image, media, font, stylesheet"
)),
}
}
}
#[derive(Default)]
pub struct ConfigBuilder {
inner: Config,
}
impl ConfigBuilder {
pub fn max_concurrent_render(mut self, n: usize) -> Self {
self.inner.max_concurrent_render = n;
self
}
pub fn max_concurrent_http(mut self, n: usize) -> Self {
self.inner.max_concurrent_http = n;
self
}
pub fn respect_robots_txt(mut self, v: bool) -> Self {
self.inner.respect_robots_txt = v;
self
}
pub fn crawl_purposes(mut self, p: Vec<crate::robots::Purpose>) -> Self {
self.inner.crawl_purposes = p;
self
}
pub fn user_agent_profile(mut self, p: Profile) -> Self {
self.inner.user_agent_profile = p;
self
}
pub fn wait_strategy(mut self, w: WaitStrategy) -> Self {
self.inner.wait_strategy = w;
self
}
pub fn queue(mut self, q: QueueBackend) -> Self {
self.inner.queue_backend = q;
self
}
pub fn storage(mut self, s: StorageBackend) -> Self {
self.inner.storage_backend = s;
self
}
pub fn proxy(mut self, p: ProxyConfig) -> Self {
self.inner.proxy = p;
self
}
pub fn build(self) -> crate::Result<Config> {
Ok(self.inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> url::Url {
url::Url::parse(s).expect("test url parses")
}
#[test]
fn warmup_default_is_disabled() {
let p = WarmupPolicy::default();
assert!(!p.enabled);
assert_eq!(p.url_template, "{origin}");
assert_eq!(p.dwell_ms, 1500);
}
#[test]
fn config_default_warmup_is_disabled() {
let c = Config::default();
assert!(!c.warmup.enabled);
}
#[test]
fn render_template_empty_yields_empty_string() {
let p = WarmupPolicy {
enabled: true,
url_template: String::new(),
dwell_ms: 0,
};
assert_eq!(p.render_template(&url("https://example.com/foo")), "");
}
#[test]
fn render_template_origin_placeholder() {
let p = WarmupPolicy {
enabled: true,
url_template: "{origin}".to_string(),
dwell_ms: 0,
};
assert_eq!(
p.render_template(&url("https://example.com/foo/bar")),
"https://example.com"
);
}
#[test]
fn render_template_origin_preserves_non_default_port() {
let p = WarmupPolicy {
enabled: true,
url_template: "{origin}".to_string(),
dwell_ms: 0,
};
assert_eq!(
p.render_template(&url("https://example.com:8443/x")),
"https://example.com:8443"
);
}
#[test]
fn render_template_host_placeholder() {
let p = WarmupPolicy {
enabled: true,
url_template: "{host}".to_string(),
dwell_ms: 0,
};
assert_eq!(
p.render_template(&url("https://sub.example.com/foo")),
"sub.example.com"
);
}
#[test]
fn render_template_mixed_placeholders() {
let p = WarmupPolicy {
enabled: true,
url_template: "{origin}/search?q={host}".to_string(),
dwell_ms: 0,
};
assert_eq!(
p.render_template(&url("https://example.com/x")),
"https://example.com/search?q=example.com"
);
}
#[test]
fn render_template_literal_without_placeholders_passes_through() {
let p = WarmupPolicy {
enabled: true,
url_template: "https://www.google.com/".to_string(),
dwell_ms: 0,
};
assert_eq!(
p.render_template(&url("https://example.com/foo")),
"https://www.google.com/"
);
}
#[test]
fn reject_resource_type_default_is_empty() {
let c = Config::default();
assert!(c.reject_resource_types.is_empty());
}
#[test]
fn reject_resource_type_from_str_covers_canonical_set() {
use std::str::FromStr;
assert_eq!(
RejectResourceType::from_str("image").unwrap(),
RejectResourceType::Image
);
assert_eq!(
RejectResourceType::from_str("MEDIA").unwrap(),
RejectResourceType::Media
);
assert_eq!(
RejectResourceType::from_str(" font ").unwrap(),
RejectResourceType::Font
);
assert_eq!(
RejectResourceType::from_str("stylesheet").unwrap(),
RejectResourceType::Stylesheet
);
assert!(RejectResourceType::from_str("script").is_err());
}
#[test]
fn reject_resource_type_url_patterns_are_nonempty_for_each_variant() {
for rt in [
RejectResourceType::Image,
RejectResourceType::Media,
RejectResourceType::Font,
RejectResourceType::Stylesheet,
] {
assert!(!rt.url_patterns().is_empty(), "{:?}", rt);
}
}
#[test]
fn browser_provider_default_is_stock() {
let c = Config::default();
assert_eq!(c.browser_provider, BrowserProvider::Stock);
}
#[test]
fn browser_provider_parse_accepts_canonical_names() {
assert_eq!(
BrowserProvider::parse("stock"),
Some(BrowserProvider::Stock)
);
assert_eq!(BrowserProvider::parse("CDP"), Some(BrowserProvider::Cdp));
assert_eq!(
BrowserProvider::parse(" auto "),
Some(BrowserProvider::Auto)
);
assert_eq!(BrowserProvider::parse("bogus_provider"), None);
assert_eq!(BrowserProvider::parse(""), None);
}
#[test]
fn browser_provider_serde_roundtrip_snake_case() {
let json = serde_json::to_string(&BrowserProvider::Cdp).unwrap();
assert_eq!(json, "\"cdp\"");
let back: BrowserProvider = serde_json::from_str("\"auto\"").unwrap();
assert_eq!(back, BrowserProvider::Auto);
}
#[test]
fn external_cdp_session_mode_default_is_isolated() {
let c = Config::default();
assert_eq!(
c.external_cdp_session_mode,
ExternalCdpSessionMode::Isolated
);
assert!(!c.external_cdp_session_mode.is_persistent());
}
#[test]
fn external_cdp_session_mode_parse_accepts_canonical_names() {
assert_eq!(
ExternalCdpSessionMode::parse("isolated"),
Some(ExternalCdpSessionMode::Isolated)
);
assert_eq!(
ExternalCdpSessionMode::parse("PERSISTENT"),
Some(ExternalCdpSessionMode::Persistent)
);
assert_eq!(
ExternalCdpSessionMode::parse(" persistent "),
Some(ExternalCdpSessionMode::Persistent)
);
assert_eq!(ExternalCdpSessionMode::parse("ephemeral"), None);
assert_eq!(ExternalCdpSessionMode::parse(""), None);
}
#[test]
fn external_cdp_session_mode_serde_roundtrip_snake_case() {
let json = serde_json::to_string(&ExternalCdpSessionMode::Persistent).unwrap();
assert_eq!(json, "\"persistent\"");
let back: ExternalCdpSessionMode = serde_json::from_str("\"isolated\"").unwrap();
assert_eq!(back, ExternalCdpSessionMode::Isolated);
}
#[test]
fn external_cdp_session_mode_as_str_round_trips_via_parse() {
for mode in [
ExternalCdpSessionMode::Isolated,
ExternalCdpSessionMode::Persistent,
] {
let s = mode.as_str();
assert_eq!(ExternalCdpSessionMode::parse(s), Some(mode));
}
}
#[test]
fn provider_fallback_default_is_disabled_and_empty() {
let c = Config::default();
assert!(!c.provider_fallback.enabled);
assert!(c.provider_fallback.order.is_empty());
assert!(!c.provider_fallback.is_active());
assert!(c
.provider_fallback
.chain_after(BrowserProvider::Cdp)
.is_empty());
}
#[test]
fn provider_fallback_inactive_unless_enabled_and_nonempty() {
let mut fb = ProviderFallbackConfig::default();
fb.order = vec![BrowserProvider::Stock];
assert!(!fb.is_active(), "order without enable must stay inert");
let mut fb = ProviderFallbackConfig::default();
fb.enabled = true;
assert!(!fb.is_active(), "enable without order must stay inert");
}
#[test]
fn provider_fallback_chain_skips_primary_and_dedupes() {
let fb = ProviderFallbackConfig {
enabled: true,
order: vec![
BrowserProvider::Cdp,
BrowserProvider::Stock,
BrowserProvider::Cdp,
],
};
assert_eq!(
fb.chain_after(BrowserProvider::Cdp),
vec![BrowserProvider::Stock]
);
assert_eq!(
fb.chain_after(BrowserProvider::Stock),
vec![BrowserProvider::Cdp]
);
}
#[test]
fn provider_fallback_chain_drops_auto() {
let fb = ProviderFallbackConfig {
enabled: true,
order: vec![BrowserProvider::Auto, BrowserProvider::Stock],
};
assert_eq!(
fb.chain_after(BrowserProvider::Cdp),
vec![BrowserProvider::Stock]
);
}
#[test]
fn provider_fallback_serde_roundtrip_snake_case() {
let fb = ProviderFallbackConfig {
enabled: true,
order: vec![BrowserProvider::Stock, BrowserProvider::Cdp],
};
let json = serde_json::to_string(&fb).unwrap();
assert!(json.contains("\"enabled\":true"), "{json}");
assert!(json.contains("\"order\":[\"stock\",\"cdp\"]"), "{json}");
let back: ProviderFallbackConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, fb);
}
#[test]
fn reject_resource_type_serde_roundtrip_lowercase() {
let json = serde_json::to_string(&RejectResourceType::Stylesheet).unwrap();
assert_eq!(json, "\"stylesheet\"");
let back: RejectResourceType = serde_json::from_str("\"image\"").unwrap();
assert_eq!(back, RejectResourceType::Image);
}
}