capabilities.rs (52375B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 use crate::command::LogOptions; 6 use crate::logging::Level; 7 use crate::marionette::MarionetteSettings; 8 use base64::prelude::BASE64_STANDARD; 9 use base64::Engine; 10 use mozdevice::AndroidStorageInput; 11 use mozprofile::preferences::Pref; 12 use mozprofile::profile::Profile; 13 use mozrunner::firefox_args::{get_arg_value, parse_args, Arg}; 14 use mozrunner::runner::platform::firefox_default_path; 15 use mozversion::{firefox_binary_version, firefox_version, Version}; 16 use regex::bytes::Regex; 17 use serde_json::{Map, Value}; 18 use std::collections::BTreeMap; 19 use std::default::Default; 20 use std::ffi::OsString; 21 use std::fs; 22 use std::io; 23 use std::io::BufWriter; 24 use std::io::Cursor; 25 use std::path::{Path, PathBuf}; 26 use std::str::{self, FromStr}; 27 use thiserror::Error; 28 use webdriver::capabilities::{BrowserCapabilities, Capabilities}; 29 use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; 30 31 #[derive(Clone, Debug, Error)] 32 enum VersionError { 33 #[error(transparent)] 34 VersionError(#[from] mozversion::Error), 35 #[error("No binary provided")] 36 MissingBinary, 37 } 38 39 impl From<VersionError> for WebDriverError { 40 fn from(err: VersionError) -> WebDriverError { 41 WebDriverError::new(ErrorStatus::SessionNotCreated, err.to_string()) 42 } 43 } 44 45 /// Provides matching of `moz:firefoxOptions` and resolutionnized of which Firefox 46 /// binary to use. 47 /// 48 /// `FirefoxCapabilities` is constructed with the fallback binary, should 49 /// `moz:firefoxOptions` not contain a binary entry. This may either be the 50 /// system Firefox installation or an override, for example given to the 51 /// `--binary` flag of geckodriver. 52 pub struct FirefoxCapabilities<'a> { 53 pub chosen_binary: Option<PathBuf>, 54 fallback_binary: Option<&'a PathBuf>, 55 version_cache: BTreeMap<PathBuf, Result<Version, VersionError>>, 56 } 57 58 impl<'a> FirefoxCapabilities<'a> { 59 pub fn new(fallback_binary: Option<&'a PathBuf>) -> FirefoxCapabilities<'a> { 60 FirefoxCapabilities { 61 chosen_binary: None, 62 fallback_binary, 63 version_cache: BTreeMap::new(), 64 } 65 } 66 67 fn set_binary(&mut self, capabilities: &Map<String, Value>) { 68 self.chosen_binary = capabilities 69 .get("moz:firefoxOptions") 70 .and_then(|x| x.get("binary")) 71 .and_then(|x| x.as_str()) 72 .map(PathBuf::from) 73 .or_else(|| self.fallback_binary.cloned()) 74 .or_else(firefox_default_path); 75 } 76 77 fn version(&mut self, binary: Option<&Path>) -> Result<Version, VersionError> { 78 if let Some(binary) = binary { 79 if let Some(cache_value) = self.version_cache.get(binary) { 80 return cache_value.clone(); 81 } 82 let rv = self 83 .version_from_ini(binary) 84 .or_else(|_| self.version_from_binary(binary)); 85 if let Ok(ref version) = rv { 86 debug!("Found version {}", version); 87 } else { 88 debug!("Failed to get binary version"); 89 } 90 self.version_cache.insert(binary.to_path_buf(), rv.clone()); 91 rv 92 } else { 93 Err(VersionError::MissingBinary) 94 } 95 } 96 97 fn version_from_ini(&self, binary: &Path) -> Result<Version, VersionError> { 98 debug!("Trying to read firefox version from ini files"); 99 let version = firefox_version(binary)?; 100 if let Some(version_string) = version.version_string { 101 Version::from_str(&version_string).map_err(|err| err.into()) 102 } else { 103 Err(VersionError::VersionError( 104 mozversion::Error::MetadataError("Missing version string".into()), 105 )) 106 } 107 } 108 109 fn version_from_binary(&self, binary: &Path) -> Result<Version, VersionError> { 110 debug!("Trying to read firefox version from binary"); 111 Ok(firefox_binary_version(binary)?) 112 } 113 } 114 115 impl BrowserCapabilities for FirefoxCapabilities<'_> { 116 fn init(&mut self, capabilities: &Capabilities) { 117 self.set_binary(capabilities); 118 } 119 120 fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { 121 Ok(Some("firefox".into())) 122 } 123 124 fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { 125 let binary = self.chosen_binary.clone(); 126 self.version(binary.as_ref().map(|x| x.as_ref())) 127 .map_err(|err| err.into()) 128 .map(|x| Some(x.to_string())) 129 } 130 131 fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { 132 Ok(if cfg!(target_os = "windows") { 133 Some("windows".into()) 134 } else if cfg!(target_os = "macos") { 135 Some("mac".into()) 136 } else if cfg!(target_os = "linux") { 137 Some("linux".into()) 138 } else { 139 None 140 }) 141 } 142 143 fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 144 Ok(true) 145 } 146 147 fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult<bool> { 148 Ok(true) 149 } 150 151 fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 152 Ok(true) 153 } 154 155 fn compare_browser_version( 156 &mut self, 157 version: &str, 158 comparison: &str, 159 ) -> WebDriverResult<bool> { 160 Version::from_str(version) 161 .map_err(VersionError::from)? 162 .matches(comparison) 163 .map_err(|err| VersionError::from(err).into()) 164 } 165 166 fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 167 Ok(true) 168 } 169 170 fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 171 Ok(true) 172 } 173 174 fn webauthn_virtual_authenticators(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 175 Ok(true) 176 } 177 178 fn webauthn_extension_uvm(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 179 Ok(false) 180 } 181 182 fn webauthn_extension_prf(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 183 Ok(false) 184 } 185 186 fn webauthn_extension_large_blob(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 187 Ok(false) 188 } 189 190 fn webauthn_extension_cred_blob(&mut self, _: &Capabilities) -> WebDriverResult<bool> { 191 Ok(false) 192 } 193 194 fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()> { 195 if !name.starts_with("moz:") { 196 return Ok(()); 197 } 198 match name { 199 "moz:firefoxOptions" => { 200 let data = try_opt!( 201 value.as_object(), 202 ErrorStatus::InvalidArgument, 203 "moz:firefoxOptions is not an object" 204 ); 205 for (key, value) in data.iter() { 206 match &**key { 207 "androidActivity" 208 | "androidDeviceSerial" 209 | "androidPackage" 210 | "profile" => { 211 if !value.is_string() { 212 return Err(WebDriverError::new( 213 ErrorStatus::InvalidArgument, 214 format!("{} is not a string", &**key), 215 )); 216 } 217 } 218 "androidIntentArguments" | "args" => { 219 if !try_opt!( 220 value.as_array(), 221 ErrorStatus::InvalidArgument, 222 format!("{} is not an array", &**key) 223 ) 224 .iter() 225 .all(|value| value.is_string()) 226 { 227 return Err(WebDriverError::new( 228 ErrorStatus::InvalidArgument, 229 format!("{} entry is not a string", &**key), 230 )); 231 } 232 } 233 "binary" => { 234 if let Some(binary) = value.as_str() { 235 if !data.contains_key("androidPackage") 236 && self.version(Some(Path::new(binary))).is_err() 237 { 238 return Err(WebDriverError::new( 239 ErrorStatus::InvalidArgument, 240 format!("{} is not a Firefox executable", &**key), 241 )); 242 } 243 } else { 244 return Err(WebDriverError::new( 245 ErrorStatus::InvalidArgument, 246 format!("{} is not a string", &**key), 247 )); 248 } 249 } 250 "env" => { 251 let env_data = try_opt!( 252 value.as_object(), 253 ErrorStatus::InvalidArgument, 254 "env value is not an object" 255 ); 256 if !env_data.values().all(Value::is_string) { 257 return Err(WebDriverError::new( 258 ErrorStatus::InvalidArgument, 259 "Environment values were not all strings", 260 )); 261 } 262 } 263 "log" => { 264 let log_data = try_opt!( 265 value.as_object(), 266 ErrorStatus::InvalidArgument, 267 "log value is not an object" 268 ); 269 for (log_key, log_value) in log_data.iter() { 270 match &**log_key { 271 "level" => { 272 let level = try_opt!( 273 log_value.as_str(), 274 ErrorStatus::InvalidArgument, 275 "log level is not a string" 276 ); 277 if Level::from_str(level).is_err() { 278 return Err(WebDriverError::new( 279 ErrorStatus::InvalidArgument, 280 format!("Not a valid log level: {}", level), 281 )); 282 } 283 } 284 x => { 285 return Err(WebDriverError::new( 286 ErrorStatus::InvalidArgument, 287 format!("Invalid log field {}", x), 288 )) 289 } 290 } 291 } 292 } 293 "prefs" => { 294 let prefs_data = try_opt!( 295 value.as_object(), 296 ErrorStatus::InvalidArgument, 297 "prefs value is not an object" 298 ); 299 let is_pref_value_type = |x: &Value| { 300 x.is_string() || x.is_i64() || x.is_u64() || x.is_boolean() 301 }; 302 if !prefs_data.values().all(is_pref_value_type) { 303 return Err(WebDriverError::new( 304 ErrorStatus::InvalidArgument, 305 "Preference values not all string or integer or boolean", 306 )); 307 } 308 } 309 x => { 310 return Err(WebDriverError::new( 311 ErrorStatus::InvalidArgument, 312 format!("Invalid moz:firefoxOptions field {}", x), 313 )) 314 } 315 } 316 } 317 } 318 "moz:webdriverClick" => { 319 if !value.is_boolean() { 320 return Err(WebDriverError::new( 321 ErrorStatus::InvalidArgument, 322 "moz:webdriverClick is not a boolean", 323 )); 324 } 325 } 326 // Bug 1967916: Remove when Firefox 140 is no longer supported 327 "moz:debuggerAddress" => { 328 if !value.is_boolean() { 329 return Err(WebDriverError::new( 330 ErrorStatus::InvalidArgument, 331 "moz:debuggerAddress is not a boolean", 332 )); 333 } 334 } 335 _ => { 336 return Err(WebDriverError::new( 337 ErrorStatus::InvalidArgument, 338 format!("Unrecognised option {}", name), 339 )) 340 } 341 } 342 Ok(()) 343 } 344 345 fn accept_custom(&mut self, _: &str, _: &Value, _: &Capabilities) -> WebDriverResult<bool> { 346 Ok(true) 347 } 348 } 349 350 /// Android-specific options in the `moz:firefoxOptions` struct. 351 /// These map to "androidCamelCase", following [chromedriver's Android-specific 352 /// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android). 353 #[derive(Default, Clone, Debug, PartialEq)] 354 pub struct AndroidOptions { 355 pub activity: Option<String>, 356 pub device_serial: Option<String>, 357 pub intent_arguments: Option<Vec<String>>, 358 pub package: String, 359 pub storage: AndroidStorageInput, 360 } 361 362 impl AndroidOptions { 363 pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions { 364 AndroidOptions { 365 package, 366 storage, 367 ..Default::default() 368 } 369 } 370 } 371 372 #[derive(Debug, Default, PartialEq)] 373 pub enum ProfileType { 374 Path(Profile), 375 Named, 376 #[default] 377 Temporary, 378 } 379 380 /// Rust representation of `moz:firefoxOptions`. 381 /// 382 /// Calling `FirefoxOptions::from_capabilities(binary, capabilities)` causes 383 /// the encoded profile, the binary arguments, log settings, and additional 384 /// preferences to be checked and unmarshaled from the `moz:firefoxOptions` 385 /// JSON Object into a Rust representation. 386 #[derive(Default, Debug)] 387 pub struct FirefoxOptions { 388 pub binary: Option<PathBuf>, 389 pub profile: ProfileType, 390 pub args: Option<Vec<String>>, 391 pub env: Option<Vec<(String, String)>>, 392 pub log: LogOptions, 393 pub prefs: Vec<(String, Pref)>, 394 pub android: Option<AndroidOptions>, 395 pub use_websocket: bool, 396 } 397 398 impl FirefoxOptions { 399 pub fn new() -> FirefoxOptions { 400 Default::default() 401 } 402 403 pub(crate) fn from_capabilities( 404 binary_path: Option<PathBuf>, 405 settings: &MarionetteSettings, 406 matched: &mut Capabilities, 407 ) -> WebDriverResult<FirefoxOptions> { 408 let mut rv = FirefoxOptions::new(); 409 rv.binary = binary_path; 410 411 if let Some(json) = matched.remove("moz:firefoxOptions") { 412 let options = json.as_object().ok_or_else(|| { 413 WebDriverError::new( 414 ErrorStatus::InvalidArgument, 415 "'moz:firefoxOptions' \ 416 capability is not an object", 417 ) 418 })?; 419 420 if options.get("androidPackage").is_some() && options.get("binary").is_some() { 421 return Err(WebDriverError::new( 422 ErrorStatus::InvalidArgument, 423 "androidPackage and binary are mutual exclusive", 424 )); 425 } 426 427 rv.android = FirefoxOptions::load_android(settings.android_storage, options)?; 428 rv.args = FirefoxOptions::load_args(options)?; 429 rv.env = FirefoxOptions::load_env(options)?; 430 rv.log = FirefoxOptions::load_log(options)?; 431 rv.prefs = FirefoxOptions::load_prefs(options)?; 432 if let Some(profile) = 433 FirefoxOptions::load_profile(settings.profile_root.as_deref(), options)? 434 { 435 rv.profile = ProfileType::Path(profile); 436 } 437 } 438 439 if let Some(args) = rv.args.as_ref() { 440 let os_args = parse_args(args.iter().map(OsString::from).collect::<Vec<_>>().iter()); 441 442 if let Some(path) = get_arg_value(os_args.iter(), Arg::Profile) { 443 if let ProfileType::Path(_) = rv.profile { 444 return Err(WebDriverError::new( 445 ErrorStatus::InvalidArgument, 446 "Can't provide both a --profile argument and a profile", 447 )); 448 } 449 let path_buf = PathBuf::from(path); 450 rv.profile = ProfileType::Path(Profile::new_from_path(&path_buf)?); 451 } 452 453 if get_arg_value(os_args.iter(), Arg::NamedProfile).is_some() { 454 if let ProfileType::Path(_) = rv.profile { 455 return Err(WebDriverError::new( 456 ErrorStatus::InvalidArgument, 457 "Can't provide both a -P argument and a profile", 458 )); 459 } 460 // See bug 1757720 461 warn!("Firefox was configured to use a named profile (`-P <name>`). \ 462 Support for named profiles will be removed in a future geckodriver release. \ 463 Please instead use the `--profile <path>` Firefox argument to start with an existing profile"); 464 rv.profile = ProfileType::Named; 465 } 466 467 // Block these Firefox command line arguments that should not be settable 468 // via session capabilities. 469 if let Some(arg) = os_args 470 .iter() 471 .filter_map(|(opt_arg, _)| opt_arg.as_ref()) 472 .find(|arg| { 473 matches!( 474 arg, 475 Arg::Marionette 476 | Arg::RemoteAllowHosts 477 | Arg::RemoteAllowOrigins 478 | Arg::RemoteDebuggingPort 479 ) 480 }) 481 { 482 return Err(WebDriverError::new( 483 ErrorStatus::InvalidArgument, 484 format!("Argument {} can't be set via capabilities", arg), 485 )); 486 }; 487 } 488 489 let has_web_socket_url = matched 490 .get("webSocketUrl") 491 .and_then(|x| x.as_bool()) 492 .unwrap_or(false); 493 494 let has_debugger_address = matched 495 .remove("moz:debuggerAddress") 496 .and_then(|x| x.as_bool()) 497 .unwrap_or(false); 498 499 // Set a command line provided port for the Remote Agent for now. 500 // It needs to be the same on the host and the Android device. 501 if has_web_socket_url || has_debugger_address { 502 rv.use_websocket = true; 503 504 // Bug 1722863: Setting of command line arguments would be 505 // better suited in the individual Browser implementations. 506 let mut remote_args = Vec::new(); 507 remote_args.push("--remote-debugging-port".to_owned()); 508 remote_args.push(settings.websocket_port.to_string()); 509 510 // Handle additional hosts for WebDriver BiDi WebSocket connections 511 if !settings.allow_hosts.is_empty() { 512 remote_args.push("--remote-allow-hosts".to_owned()); 513 remote_args.push( 514 settings 515 .allow_hosts 516 .iter() 517 .map(|host| host.to_string()) 518 .collect::<Vec<String>>() 519 .join(","), 520 ); 521 } 522 523 // Handle additional origins for WebDriver BiDi WebSocket connections 524 if !settings.allow_origins.is_empty() { 525 remote_args.push("--remote-allow-origins".to_owned()); 526 remote_args.push( 527 settings 528 .allow_origins 529 .iter() 530 .map(|origin| origin.to_string()) 531 .collect::<Vec<String>>() 532 .join(","), 533 ); 534 } 535 536 if let Some(ref mut args) = rv.args { 537 args.append(&mut remote_args); 538 } else { 539 rv.args = Some(remote_args); 540 } 541 } 542 543 Ok(rv) 544 } 545 546 fn load_profile( 547 profile_root: Option<&Path>, 548 options: &Capabilities, 549 ) -> WebDriverResult<Option<Profile>> { 550 if let Some(profile_json) = options.get("profile") { 551 let profile_base64 = profile_json.as_str().ok_or_else(|| { 552 WebDriverError::new(ErrorStatus::InvalidArgument, "Profile is not a string") 553 })?; 554 let profile_zip = &*BASE64_STANDARD.decode(profile_base64)?; 555 556 // Create an emtpy profile directory 557 let profile = Profile::new(profile_root)?; 558 unzip_buffer( 559 profile_zip, 560 profile 561 .temp_dir 562 .as_ref() 563 .expect("Profile doesn't have a path") 564 .path(), 565 )?; 566 567 Ok(Some(profile)) 568 } else { 569 Ok(None) 570 } 571 } 572 573 fn load_args(options: &Capabilities) -> WebDriverResult<Option<Vec<String>>> { 574 if let Some(args_json) = options.get("args") { 575 let args_array = args_json.as_array().ok_or_else(|| { 576 WebDriverError::new(ErrorStatus::InvalidArgument, "Arguments were not an array") 577 })?; 578 let args = args_array 579 .iter() 580 .map(|x| x.as_str().map(|x| x.to_owned())) 581 .collect::<Option<Vec<String>>>() 582 .ok_or_else(|| { 583 WebDriverError::new( 584 ErrorStatus::InvalidArgument, 585 "Arguments entries were not all strings", 586 ) 587 })?; 588 589 Ok(Some(args)) 590 } else { 591 Ok(None) 592 } 593 } 594 595 pub fn load_env(options: &Capabilities) -> WebDriverResult<Option<Vec<(String, String)>>> { 596 if let Some(env_data) = options.get("env") { 597 let env = env_data.as_object().ok_or_else(|| { 598 WebDriverError::new(ErrorStatus::InvalidArgument, "Env was not an object") 599 })?; 600 let mut rv = Vec::with_capacity(env.len()); 601 for (key, value) in env.iter() { 602 rv.push(( 603 key.clone(), 604 value 605 .as_str() 606 .ok_or_else(|| { 607 WebDriverError::new( 608 ErrorStatus::InvalidArgument, 609 "Env value is not a string", 610 ) 611 })? 612 .to_string(), 613 )); 614 } 615 Ok(Some(rv)) 616 } else { 617 Ok(None) 618 } 619 } 620 621 fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> { 622 if let Some(json) = options.get("log") { 623 let log = json.as_object().ok_or_else(|| { 624 WebDriverError::new(ErrorStatus::InvalidArgument, "Log section is not an object") 625 })?; 626 627 let level = match log.get("level") { 628 Some(json) => { 629 let s = json.as_str().ok_or_else(|| { 630 WebDriverError::new( 631 ErrorStatus::InvalidArgument, 632 "Log level is not a string", 633 ) 634 })?; 635 Some(Level::from_str(s).ok().ok_or_else(|| { 636 WebDriverError::new(ErrorStatus::InvalidArgument, "Log level is unknown") 637 })?) 638 } 639 None => None, 640 }; 641 642 Ok(LogOptions { level }) 643 } else { 644 Ok(Default::default()) 645 } 646 } 647 648 pub fn load_prefs(options: &Capabilities) -> WebDriverResult<Vec<(String, Pref)>> { 649 if let Some(prefs_data) = options.get("prefs") { 650 let prefs = prefs_data.as_object().ok_or_else(|| { 651 WebDriverError::new(ErrorStatus::InvalidArgument, "Prefs were not an object") 652 })?; 653 let mut rv = Vec::with_capacity(prefs.len()); 654 for (key, value) in prefs.iter() { 655 rv.push((key.clone(), pref_from_json(value)?)); 656 } 657 Ok(rv) 658 } else { 659 Ok(vec![]) 660 } 661 } 662 663 pub fn load_android( 664 storage: AndroidStorageInput, 665 options: &Capabilities, 666 ) -> WebDriverResult<Option<AndroidOptions>> { 667 if let Some(package_json) = options.get("androidPackage") { 668 let package = package_json 669 .as_str() 670 .ok_or_else(|| { 671 WebDriverError::new( 672 ErrorStatus::InvalidArgument, 673 "androidPackage is not a string", 674 ) 675 })? 676 .to_owned(); 677 678 // https://developer.android.com/studio/build/application-id 679 let package_regexp = 680 Regex::new(r"^([a-zA-Z][a-zA-Z0-9_]*\.){1,}([a-zA-Z][a-zA-Z0-9_]*)$").unwrap(); 681 if !package_regexp.is_match(package.as_bytes()) { 682 return Err(WebDriverError::new( 683 ErrorStatus::InvalidArgument, 684 "Not a valid androidPackage name", 685 )); 686 } 687 688 let mut android = AndroidOptions::new(package.clone(), storage); 689 690 android.activity = match options.get("androidActivity") { 691 Some(json) => { 692 let activity = json 693 .as_str() 694 .ok_or_else(|| { 695 WebDriverError::new( 696 ErrorStatus::InvalidArgument, 697 "androidActivity is not a string", 698 ) 699 })? 700 .to_owned(); 701 702 if activity.contains('/') { 703 return Err(WebDriverError::new( 704 ErrorStatus::InvalidArgument, 705 "androidActivity should not contain '/", 706 )); 707 } 708 709 Some(activity) 710 } 711 None => { 712 match package.as_str() { 713 "org.mozilla.firefox" 714 | "org.mozilla.firefox_beta" 715 | "org.mozilla.fenix" 716 | "org.mozilla.fenix.debug" 717 | "org.mozilla.reference.browser" => { 718 Some("org.mozilla.fenix.IntentReceiverActivity".to_string()) 719 } 720 "org.mozilla.focus" 721 | "org.mozilla.focus.debug" 722 | "org.mozilla.klar" 723 | "org.mozilla.klar.debug" => { 724 Some("org.mozilla.focus.activity.IntentReceiverActivity".to_string()) 725 } 726 // For all other applications fallback to auto-detection. 727 _ => None, 728 } 729 } 730 }; 731 732 android.device_serial = match options.get("androidDeviceSerial") { 733 Some(json) => Some( 734 json.as_str() 735 .ok_or_else(|| { 736 WebDriverError::new( 737 ErrorStatus::InvalidArgument, 738 "androidDeviceSerial is not a string", 739 ) 740 })? 741 .to_owned(), 742 ), 743 None => None, 744 }; 745 746 android.intent_arguments = match options.get("androidIntentArguments") { 747 Some(json) => { 748 let args_array = json.as_array().ok_or_else(|| { 749 WebDriverError::new( 750 ErrorStatus::InvalidArgument, 751 "androidIntentArguments is not an array", 752 ) 753 })?; 754 let args = args_array 755 .iter() 756 .map(|x| x.as_str().map(|x| x.to_owned())) 757 .collect::<Option<Vec<String>>>() 758 .ok_or_else(|| { 759 WebDriverError::new( 760 ErrorStatus::InvalidArgument, 761 "androidIntentArguments entries are not all strings", 762 ) 763 })?; 764 765 Some(args) 766 } 767 None => { 768 // All GeckoView based applications support this view, 769 // and allow to open a blank page in a Gecko window. 770 Some(vec![ 771 "-a".to_string(), 772 "android.intent.action.VIEW".to_string(), 773 "-d".to_string(), 774 "about:blank".to_string(), 775 ]) 776 } 777 }; 778 779 Ok(Some(android)) 780 } else { 781 Ok(None) 782 } 783 } 784 } 785 786 fn pref_from_json(value: &Value) -> WebDriverResult<Pref> { 787 match *value { 788 Value::String(ref x) => Ok(Pref::new(x.clone())), 789 Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())), 790 Value::Bool(x) => Ok(Pref::new(x)), 791 _ => Err(WebDriverError::new( 792 ErrorStatus::UnknownError, 793 "Could not convert pref value to string, boolean, or integer", 794 )), 795 } 796 } 797 798 fn unzip_buffer(buf: &[u8], dest_dir: &Path) -> WebDriverResult<()> { 799 let reader = Cursor::new(buf); 800 let mut zip = zip::ZipArchive::new(reader) 801 .map_err(|_| WebDriverError::new(ErrorStatus::UnknownError, "Failed to unzip profile"))?; 802 803 for i in 0..zip.len() { 804 let mut file = zip.by_index(i).map_err(|_| { 805 WebDriverError::new( 806 ErrorStatus::UnknownError, 807 "Processing profile zip file failed", 808 ) 809 })?; 810 let unzip_path = { 811 let name = file.name(); 812 let is_dir = name.ends_with('/'); 813 let rel_path = Path::new(name); 814 let dest_path = dest_dir.join(rel_path); 815 816 { 817 let create_dir = if is_dir { 818 Some(dest_path.as_path()) 819 } else { 820 dest_path.parent() 821 }; 822 if let Some(dir) = create_dir { 823 if !dir.exists() { 824 debug!("Creating profile directory tree {}", dir.to_string_lossy()); 825 fs::create_dir_all(dir)?; 826 } 827 } 828 } 829 830 if is_dir { 831 None 832 } else { 833 Some(dest_path) 834 } 835 }; 836 837 if let Some(unzip_path) = unzip_path { 838 debug!("Extracting profile to {}", unzip_path.to_string_lossy()); 839 let dest = fs::File::create(unzip_path)?; 840 if file.size() > 0 { 841 let mut writer = BufWriter::new(dest); 842 io::copy(&mut file, &mut writer)?; 843 } 844 } 845 } 846 847 Ok(()) 848 } 849 850 #[cfg(test)] 851 mod tests { 852 extern crate mozprofile; 853 854 use self::mozprofile::preferences::Pref; 855 use super::*; 856 use serde_json::{json, Map, Value}; 857 use std::fs::File; 858 use std::io::Read; 859 use url::{Host, Url}; 860 use webdriver::capabilities::Capabilities; 861 862 fn example_profile() -> Value { 863 let mut profile_data = Vec::with_capacity(1024); 864 let mut profile = File::open("src/tests/profile.zip").unwrap(); 865 profile.read_to_end(&mut profile_data).unwrap(); 866 Value::String(BASE64_STANDARD.encode(&profile_data)) 867 } 868 869 fn make_options( 870 firefox_opts: Capabilities, 871 marionette_settings: Option<MarionetteSettings>, 872 ) -> WebDriverResult<FirefoxOptions> { 873 let mut caps = Capabilities::new(); 874 caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); 875 876 FirefoxOptions::from_capabilities(None, &marionette_settings.unwrap_or_default(), &mut caps) 877 } 878 879 #[test] 880 fn fx_options_default() { 881 let opts: FirefoxOptions = Default::default(); 882 assert_eq!(opts.android, None); 883 assert_eq!(opts.args, None); 884 assert_eq!(opts.binary, None); 885 assert_eq!(opts.log, LogOptions { level: None }); 886 assert_eq!(opts.prefs, vec![]); 887 // Profile doesn't support PartialEq 888 // assert_eq!(opts.profile, None); 889 } 890 891 #[test] 892 fn fx_options_from_capabilities_no_binary_and_empty_caps() { 893 let mut caps = Capabilities::new(); 894 895 let marionette_settings = Default::default(); 896 let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 897 .expect("valid firefox options"); 898 assert_eq!(opts.android, None); 899 assert_eq!(opts.args, None); 900 assert_eq!(opts.binary, None); 901 assert_eq!(opts.log, LogOptions { level: None }); 902 assert_eq!(opts.prefs, vec![]); 903 } 904 905 #[test] 906 fn fx_options_from_capabilities_with_binary_and_caps() { 907 let mut caps = Capabilities::new(); 908 caps.insert( 909 "moz:firefoxOptions".into(), 910 Value::Object(Capabilities::new()), 911 ); 912 913 let binary = PathBuf::from("foo"); 914 let marionette_settings = Default::default(); 915 916 let opts = FirefoxOptions::from_capabilities( 917 Some(binary.clone()), 918 &marionette_settings, 919 &mut caps, 920 ) 921 .expect("valid firefox options"); 922 assert_eq!(opts.android, None); 923 assert_eq!(opts.args, None); 924 assert_eq!(opts.binary, Some(binary)); 925 assert_eq!(opts.log, LogOptions { level: None }); 926 assert_eq!(opts.prefs, vec![]); 927 } 928 929 #[test] 930 fn fx_options_from_capabilities_with_blocked_firefox_arguments() { 931 let blocked_args = vec![ 932 "--marionette", 933 "--remote-allow-hosts", 934 "--remote-allow-origins", 935 "--remote-debugging-port", 936 ]; 937 938 for arg in blocked_args { 939 let mut firefox_opts = Capabilities::new(); 940 firefox_opts.insert("args".into(), json!([arg])); 941 942 make_options(firefox_opts, None).expect_err("invalid firefox options"); 943 } 944 } 945 946 #[test] 947 fn fx_options_from_capabilities_with_websocket_url_not_set() { 948 let mut caps = Capabilities::new(); 949 950 let marionette_settings = Default::default(); 951 let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 952 .expect("Valid Firefox options"); 953 954 assert!( 955 opts.args.is_none(), 956 "CLI arguments for Firefox unexpectedly found" 957 ); 958 } 959 960 #[test] 961 fn fx_options_from_capabilities_with_websocket_url_false() { 962 let mut caps = Capabilities::new(); 963 caps.insert("webSocketUrl".into(), json!(false)); 964 965 let marionette_settings = Default::default(); 966 let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 967 .expect("Valid Firefox options"); 968 969 assert!( 970 opts.args.is_none(), 971 "CLI arguments for Firefox unexpectedly found" 972 ); 973 } 974 975 #[test] 976 fn fx_options_from_capabilities_with_websocket_url_true() { 977 let mut caps = Capabilities::new(); 978 caps.insert("webSocketUrl".into(), json!(true)); 979 980 let settings = MarionetteSettings { 981 websocket_port: 1234, 982 ..Default::default() 983 }; 984 let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) 985 .expect("Valid Firefox options"); 986 987 if let Some(args) = opts.args { 988 let mut iter = args.iter(); 989 assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); 990 assert_eq!(iter.next(), Some(&"1234".to_owned())); 991 } else { 992 panic!("CLI arguments for Firefox not found"); 993 } 994 } 995 996 #[test] 997 fn fx_options_from_capabilities_with_websocket_and_allow_hosts() { 998 let mut caps = Capabilities::new(); 999 caps.insert("webSocketUrl".into(), json!(true)); 1000 1001 let mut marionette_settings: MarionetteSettings = Default::default(); 1002 marionette_settings.allow_hosts = vec![ 1003 Host::parse("foo").expect("host"), 1004 Host::parse("bar").expect("host"), 1005 ]; 1006 let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 1007 .expect("Valid Firefox options"); 1008 1009 if let Some(args) = opts.args { 1010 let mut iter = args.iter(); 1011 assert!(iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); 1012 assert_eq!(iter.next(), Some(&"foo,bar".to_owned())); 1013 assert!(!iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); 1014 } else { 1015 panic!("CLI arguments for Firefox not found"); 1016 } 1017 } 1018 1019 #[test] 1020 fn fx_options_from_capabilities_with_websocket_and_allow_origins() { 1021 let mut caps = Capabilities::new(); 1022 caps.insert("webSocketUrl".into(), json!(true)); 1023 1024 let mut marionette_settings: MarionetteSettings = Default::default(); 1025 marionette_settings.allow_origins = vec![ 1026 Url::parse("http://foo/").expect("url"), 1027 Url::parse("http://bar/").expect("url"), 1028 ]; 1029 let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 1030 .expect("Valid Firefox options"); 1031 1032 if let Some(args) = opts.args { 1033 let mut iter = args.iter(); 1034 assert!(iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); 1035 assert_eq!(iter.next(), Some(&"http://foo/,http://bar/".to_owned())); 1036 assert!(!iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); 1037 } else { 1038 panic!("CLI arguments for Firefox not found"); 1039 } 1040 } 1041 1042 #[test] 1043 fn fx_options_from_capabilities_with_debugger_address_not_set() { 1044 let caps = Capabilities::new(); 1045 1046 let opts = make_options(caps, None).expect("valid firefox options"); 1047 assert!( 1048 opts.args.is_none(), 1049 "CLI arguments for Firefox unexpectedly found" 1050 ); 1051 } 1052 1053 #[test] 1054 fn fx_options_from_capabilities_with_debugger_address_false() { 1055 let mut caps = Capabilities::new(); 1056 caps.insert("moz:debuggerAddress".into(), json!(false)); 1057 1058 let opts = make_options(caps, None).expect("valid firefox options"); 1059 assert!( 1060 opts.args.is_none(), 1061 "CLI arguments for Firefox unexpectedly found" 1062 ); 1063 } 1064 1065 #[test] 1066 fn fx_options_from_capabilities_with_debugger_address_true() { 1067 let mut caps = Capabilities::new(); 1068 caps.insert("moz:debuggerAddress".into(), json!(true)); 1069 1070 let settings = MarionetteSettings { 1071 websocket_port: 1234, 1072 ..Default::default() 1073 }; 1074 let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) 1075 .expect("Valid Firefox options"); 1076 1077 if let Some(args) = opts.args { 1078 let mut iter = args.iter(); 1079 assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); 1080 assert_eq!(iter.next(), Some(&"1234".to_owned())); 1081 } else { 1082 panic!("CLI arguments for Firefox not found"); 1083 } 1084 } 1085 1086 #[test] 1087 fn fx_options_from_capabilities_with_invalid_caps() { 1088 let mut caps = Capabilities::new(); 1089 caps.insert("moz:firefoxOptions".into(), json!(42)); 1090 1091 let marionette_settings = Default::default(); 1092 FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) 1093 .expect_err("Firefox options need to be of type object"); 1094 } 1095 1096 #[test] 1097 fn fx_options_android_package_and_binary() { 1098 let mut firefox_opts = Capabilities::new(); 1099 firefox_opts.insert("androidPackage".into(), json!("foo")); 1100 firefox_opts.insert("binary".into(), json!("bar")); 1101 1102 make_options(firefox_opts, None) 1103 .expect_err("androidPackage and binary are mutual exclusive"); 1104 } 1105 1106 #[test] 1107 fn fx_options_android_no_package() { 1108 let mut firefox_opts = Capabilities::new(); 1109 firefox_opts.insert("androidAvtivity".into(), json!("foo")); 1110 1111 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1112 assert_eq!(opts.android, None); 1113 } 1114 1115 #[test] 1116 fn fx_options_android_package_valid_value() { 1117 for value in ["foo.bar", "foo.bar.cheese.is.good", "Foo.Bar_9"].iter() { 1118 let mut firefox_opts = Capabilities::new(); 1119 firefox_opts.insert("androidPackage".into(), json!(value)); 1120 1121 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1122 assert_eq!(opts.android.unwrap().package, value.to_string()); 1123 } 1124 } 1125 1126 #[test] 1127 fn fx_options_android_package_invalid_type() { 1128 let mut firefox_opts = Capabilities::new(); 1129 firefox_opts.insert("androidPackage".into(), json!(42)); 1130 1131 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1132 } 1133 1134 #[test] 1135 fn fx_options_android_package_invalid_value() { 1136 for value in ["../foo", "\\foo\n", "foo", "_foo", "0foo"].iter() { 1137 let mut firefox_opts = Capabilities::new(); 1138 firefox_opts.insert("androidPackage".into(), json!(value)); 1139 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1140 } 1141 } 1142 1143 #[test] 1144 fn fx_options_android_activity_default_known_apps() { 1145 let packages = vec![ 1146 "org.mozilla.firefox", 1147 "org.mozilla.firefox_beta", 1148 "org.mozilla.fenix", 1149 "org.mozilla.fenix.debug", 1150 "org.mozilla.focus", 1151 "org.mozilla.focus.debug", 1152 "org.mozilla.klar", 1153 "org.mozilla.klar.debug", 1154 "org.mozilla.reference.browser", 1155 ]; 1156 1157 for package in packages { 1158 let mut firefox_opts = Capabilities::new(); 1159 firefox_opts.insert("androidPackage".into(), json!(package)); 1160 1161 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1162 assert!(opts 1163 .android 1164 .unwrap() 1165 .activity 1166 .unwrap() 1167 .contains("IntentReceiverActivity")); 1168 } 1169 } 1170 1171 #[test] 1172 fn fx_options_android_activity_default_unknown_apps() { 1173 let packages = vec!["org.mozilla.geckoview_example", "com.some.other.app"]; 1174 1175 for package in packages { 1176 let mut firefox_opts = Capabilities::new(); 1177 firefox_opts.insert("androidPackage".into(), json!(package)); 1178 1179 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1180 assert_eq!(opts.android.unwrap().activity, None); 1181 } 1182 1183 let mut firefox_opts = Capabilities::new(); 1184 firefox_opts.insert( 1185 "androidPackage".into(), 1186 json!("org.mozilla.geckoview_example"), 1187 ); 1188 1189 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1190 assert_eq!(opts.android.unwrap().activity, None); 1191 } 1192 1193 #[test] 1194 fn fx_options_android_activity_override() { 1195 let mut firefox_opts = Capabilities::new(); 1196 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1197 firefox_opts.insert("androidActivity".into(), json!("foo")); 1198 1199 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1200 assert_eq!(opts.android.unwrap().activity, Some("foo".to_string())); 1201 } 1202 1203 #[test] 1204 fn fx_options_android_activity_invalid_type() { 1205 let mut firefox_opts = Capabilities::new(); 1206 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1207 firefox_opts.insert("androidActivity".into(), json!(42)); 1208 1209 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1210 } 1211 1212 #[test] 1213 fn fx_options_android_activity_invalid_value() { 1214 let mut firefox_opts = Capabilities::new(); 1215 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1216 firefox_opts.insert("androidActivity".into(), json!("foo.bar/cheese")); 1217 1218 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1219 } 1220 1221 #[test] 1222 fn fx_options_android_device_serial() { 1223 let mut firefox_opts = Capabilities::new(); 1224 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1225 firefox_opts.insert("androidDeviceSerial".into(), json!("cheese")); 1226 1227 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1228 assert_eq!( 1229 opts.android.unwrap().device_serial, 1230 Some("cheese".to_string()) 1231 ); 1232 } 1233 1234 #[test] 1235 fn fx_options_android_device_serial_invalid() { 1236 let mut firefox_opts = Capabilities::new(); 1237 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1238 firefox_opts.insert("androidDeviceSerial".into(), json!(42)); 1239 1240 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1241 } 1242 1243 #[test] 1244 fn fx_options_android_intent_arguments_defaults() { 1245 let packages = vec![ 1246 "org.mozilla.firefox", 1247 "org.mozilla.firefox_beta", 1248 "org.mozilla.fenix", 1249 "org.mozilla.fenix.debug", 1250 "org.mozilla.geckoview_example", 1251 "org.mozilla.reference.browser", 1252 "com.some.other.app", 1253 ]; 1254 1255 for package in packages { 1256 let mut firefox_opts = Capabilities::new(); 1257 firefox_opts.insert("androidPackage".into(), json!(package)); 1258 1259 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1260 assert_eq!( 1261 opts.android.unwrap().intent_arguments, 1262 Some(vec![ 1263 "-a".to_string(), 1264 "android.intent.action.VIEW".to_string(), 1265 "-d".to_string(), 1266 "about:blank".to_string(), 1267 ]) 1268 ); 1269 } 1270 } 1271 1272 #[test] 1273 fn fx_options_android_intent_arguments_override() { 1274 let mut firefox_opts = Capabilities::new(); 1275 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1276 firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", "ipsum"])); 1277 1278 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1279 assert_eq!( 1280 opts.android.unwrap().intent_arguments, 1281 Some(vec!["lorem".to_string(), "ipsum".to_string()]) 1282 ); 1283 } 1284 1285 #[test] 1286 fn fx_options_android_intent_arguments_no_array() { 1287 let mut firefox_opts = Capabilities::new(); 1288 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1289 firefox_opts.insert("androidIntentArguments".into(), json!(42)); 1290 1291 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1292 } 1293 1294 #[test] 1295 fn fx_options_android_intent_arguments_invalid_value() { 1296 let mut firefox_opts = Capabilities::new(); 1297 firefox_opts.insert("androidPackage".into(), json!("foo.bar")); 1298 firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", 42])); 1299 1300 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1301 } 1302 1303 #[test] 1304 fn fx_options_env() { 1305 let mut env: Map<String, Value> = Map::new(); 1306 env.insert("TEST_KEY_A".into(), Value::String("test_value_a".into())); 1307 env.insert("TEST_KEY_B".into(), Value::String("test_value_b".into())); 1308 1309 let mut firefox_opts = Capabilities::new(); 1310 firefox_opts.insert("env".into(), env.into()); 1311 1312 let mut opts = make_options(firefox_opts, None).expect("valid firefox options"); 1313 for sorted in opts.env.iter_mut() { 1314 sorted.sort() 1315 } 1316 assert_eq!( 1317 opts.env, 1318 Some(vec![ 1319 ("TEST_KEY_A".into(), "test_value_a".into()), 1320 ("TEST_KEY_B".into(), "test_value_b".into()), 1321 ]) 1322 ); 1323 } 1324 1325 #[test] 1326 fn fx_options_env_invalid_container() { 1327 let env = Value::Number(1.into()); 1328 1329 let mut firefox_opts = Capabilities::new(); 1330 firefox_opts.insert("env".into(), env); 1331 1332 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1333 } 1334 1335 #[test] 1336 fn fx_options_env_invalid_value() { 1337 let mut env: Map<String, Value> = Map::new(); 1338 env.insert("TEST_KEY".into(), Value::Number(1.into())); 1339 1340 let mut firefox_opts = Capabilities::new(); 1341 firefox_opts.insert("env".into(), env.into()); 1342 1343 make_options(firefox_opts, None).expect_err("invalid firefox options"); 1344 } 1345 1346 #[test] 1347 fn test_profile() { 1348 let encoded_profile = example_profile(); 1349 let mut firefox_opts = Capabilities::new(); 1350 firefox_opts.insert("profile".into(), encoded_profile); 1351 1352 let opts = make_options(firefox_opts, None).expect("valid firefox options"); 1353 let mut profile = match opts.profile { 1354 ProfileType::Path(profile) => profile, 1355 _ => panic!("Expected ProfileType::Path"), 1356 }; 1357 let prefs = profile.user_prefs().expect("valid preferences"); 1358 1359 println!("{:#?}", prefs.prefs); 1360 1361 assert_eq!( 1362 prefs.get("startup.homepage_welcome_url"), 1363 Some(&Pref::new("data:text/html,PASS")) 1364 ); 1365 } 1366 1367 #[test] 1368 fn fx_options_args_profile() { 1369 let mut firefox_opts = Capabilities::new(); 1370 firefox_opts.insert("args".into(), json!(["--profile", "foo"])); 1371 1372 let options = make_options(firefox_opts, None).expect("Valid args"); 1373 assert!(matches!(options.profile, ProfileType::Path(_))); 1374 } 1375 1376 #[test] 1377 fn fx_options_args_named_profile() { 1378 let mut firefox_opts = Capabilities::new(); 1379 firefox_opts.insert("args".into(), json!(["-P", "foo"])); 1380 1381 let options = make_options(firefox_opts, None).expect("Valid args"); 1382 assert!(matches!(options.profile, ProfileType::Named)); 1383 } 1384 1385 #[test] 1386 fn fx_options_args_no_profile() { 1387 let mut firefox_opts = Capabilities::new(); 1388 firefox_opts.insert("args".into(), json!(["--headless"])); 1389 1390 let options = make_options(firefox_opts, None).expect("Valid args"); 1391 assert!(matches!(options.profile, ProfileType::Temporary)); 1392 } 1393 1394 #[test] 1395 fn fx_options_args_profile_and_profile() { 1396 let mut firefox_opts = Capabilities::new(); 1397 firefox_opts.insert("args".into(), json!(["--profile", "foo"])); 1398 firefox_opts.insert("profile".into(), json!("foo")); 1399 1400 make_options(firefox_opts, None).expect_err("Invalid args"); 1401 } 1402 1403 #[test] 1404 fn fx_options_args_p_and_profile() { 1405 let mut firefox_opts = Capabilities::new(); 1406 firefox_opts.insert("args".into(), json!(["-P"])); 1407 firefox_opts.insert("profile".into(), json!("foo")); 1408 1409 make_options(firefox_opts, None).expect_err("Invalid args"); 1410 } 1411 }