Skip to main content

browser_commander/browser/webdriver/
capabilities.rs

1use super::{WebDriverBrowser, WebDriverOptions};
2use anyhow::{anyhow, Result};
3use serde_json::{json, Value};
4use std::path::Path;
5
6/// Build W3C capabilities while preserving caller extensions. Managed profile,
7/// downloads and BiDi values are authoritative, so an override cannot silently
8/// redirect files away from their watcher or abandon the owned profile.
9pub fn build_capabilities(
10    options: &WebDriverOptions,
11    profile: &Path,
12    staging: Option<&Path>,
13) -> Result<serde_json::Map<String, Value>> {
14    let mut preferences = options
15        .preferences
16        .as_object()
17        .cloned()
18        .ok_or_else(|| anyhow!("WebDriver preferences must be an object"))?;
19    let mut caps = options.capabilities.clone();
20    if options.browser.is_safari() {
21        use crate::browser::safari::unsupported;
22        for (feature, requested) in [
23            ("headless", options.headless),
24            ("persistent profile", options.user_data_dir.is_some()),
25            ("browser arguments", !options.args.is_empty()),
26            (
27                "preferences",
28                !preferences.is_empty() || options.local_state != json!({}),
29            ),
30            (
31                "browser executable (use driver_executable)",
32                options.browser_executable.is_some(),
33            ),
34            (
35                "managed downloads",
36                !matches!(options.downloads, crate::downloads::DownloadSetting::Off),
37            ),
38            (
39                "first-run settings",
40                options.first_run || options.default_browser_check == Some(true),
41            ),
42        ] {
43            if requested {
44                return Err(unsupported(feature).into());
45            }
46        }
47        for key in ["goog:chromeOptions", "moz:firefoxOptions", "webSocketUrl"] {
48            if caps.contains_key(key) {
49                return Err(unsupported(key).into());
50            }
51        }
52        caps.insert(
53            "browserName".into(),
54            json!(
55                if options.browser == WebDriverBrowser::SafariTechnologyPreview {
56                    "Safari Technology Preview"
57                } else {
58                    "safari"
59                }
60            ),
61        );
62        return Ok(caps);
63    }
64    let mut args = options.args.clone();
65    let key = match options.browser {
66        WebDriverBrowser::Chrome => "goog:chromeOptions",
67        WebDriverBrowser::Firefox => "moz:firefoxOptions",
68        _ => unreachable!("Safari capabilities returned above"),
69    };
70    let mut vendor = match caps.remove(key) {
71        Some(Value::Object(options)) => options,
72        Some(_) => return Err(anyhow!("{key} must be an object")),
73        None => Default::default(),
74    };
75    if let Some(extra) = vendor.remove("args") {
76        let extra: Vec<String> = serde_json::from_value(extra)?;
77        args.extend(extra);
78    }
79    if let Some(extra) = vendor.remove("prefs") {
80        let Value::Object(extra) = extra else {
81            return Err(anyhow!("{key}.prefs must be an object"));
82        };
83        for (key, value) in extra {
84            preferences.entry(key).or_insert(value);
85        }
86    }
87    match options.browser {
88        WebDriverBrowser::Chrome => {
89            if options.automation_parity {
90                let mut excluded: Vec<String> = vendor
91                    .remove("excludeSwitches")
92                    .map(serde_json::from_value)
93                    .transpose()?
94                    .unwrap_or_default();
95                // Same measured ChromeDriver layer as the JavaScript launcher.
96                for name in [
97                    "allow-pre-commit-input",
98                    "disable-background-networking",
99                    "disable-background-timer-throttling",
100                    "disable-backgrounding-occluded-windows",
101                    "disable-client-side-phishing-detection",
102                    "disable-default-apps",
103                    "disable-features",
104                    "disable-hang-monitor",
105                    "disable-popup-blocking",
106                    "disable-prompt-on-repost",
107                    "disable-sync",
108                    "enable-automation",
109                    "enable-logging",
110                    "log-level",
111                    "no-first-run",
112                    "no-service-autorun",
113                    "password-store",
114                    "test-type",
115                    "use-mock-keychain",
116                ] {
117                    if !excluded.iter().any(|value| value == name) {
118                        excluded.push(name.into());
119                    }
120                }
121                vendor.insert("excludeSwitches".into(), json!(excluded));
122            }
123            if args
124                .iter()
125                .any(|arg| arg.trim_start_matches('-').starts_with("user-data-dir"))
126            {
127                return Err(anyhow!(
128                    "set user_data_dir instead of a Chrome profile argument"
129                ));
130            }
131            args.push(format!("--user-data-dir={}", profile.display()));
132            if options.headless {
133                args.push("--headless=new".into());
134            }
135            if !options.sandbox {
136                args.push("--no-sandbox".into());
137            }
138            if let Some(path) = staging {
139                preferences.insert("download.default_directory".into(), json!(path));
140                preferences.insert("download.prompt_for_download".into(), json!(false));
141                preferences.insert("download.directory_upgrade".into(), json!(true));
142            }
143            caps.insert("browserName".into(), json!("chrome"));
144        }
145        WebDriverBrowser::Firefox => {
146            if vendor.contains_key("profile") {
147                return Err(anyhow!(
148                    "set user_data_dir instead of moz:firefoxOptions.profile"
149                ));
150            }
151            if args.iter().any(|arg| {
152                matches!(arg.as_str(), "-profile" | "--profile") || arg.starts_with("--profile=")
153            }) {
154                return Err(anyhow!(
155                    "set user_data_dir instead of a Firefox profile argument"
156                ));
157            }
158            args.extend(["-profile".into(), profile.to_string_lossy().into_owned()]);
159            if options.headless {
160                args.push("-headless".into());
161            }
162            if let Some(path) = staging {
163                preferences.insert("browser.download.dir".into(), json!(path));
164                preferences.insert("browser.download.folderList".into(), json!(2));
165                preferences.insert("browser.download.useDownloadDir".into(), json!(true));
166                preferences
167                    .entry("browser.helperApps.neverAsk.saveToDisk")
168                    .or_insert(json!(
169                    "application/octet-stream,text/plain,application/pdf,text/csv,application/zip"
170                ));
171                preferences.insert("pdfjs.disabled".into(), json!(true));
172            }
173            caps.insert("browserName".into(), json!("firefox"));
174        }
175        _ => unreachable!("Safari capabilities returned above"),
176    }
177    vendor.insert("args".into(), json!(args));
178    vendor.insert("prefs".into(), json!(preferences));
179    if let Some(binary) = &options.browser_executable {
180        vendor.insert("binary".into(), json!(binary));
181    }
182    caps.insert(key.into(), json!(vendor));
183    if options.bidi {
184        caps.insert("webSocketUrl".into(), json!(true));
185    }
186    Ok(caps)
187}