diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/geckodriver/src | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/geckodriver/src')
-rw-r--r-- | testing/geckodriver/src/android.rs | 470 | ||||
-rw-r--r-- | testing/geckodriver/src/build.rs | 49 | ||||
-rw-r--r-- | testing/geckodriver/src/capabilities.rs | 1088 | ||||
-rw-r--r-- | testing/geckodriver/src/command.rs | 342 | ||||
-rw-r--r-- | testing/geckodriver/src/logging.rs | 351 | ||||
-rw-r--r-- | testing/geckodriver/src/main.rs | 351 | ||||
-rw-r--r-- | testing/geckodriver/src/marionette.rs | 1749 | ||||
-rw-r--r-- | testing/geckodriver/src/prefs.rs | 160 | ||||
-rw-r--r-- | testing/geckodriver/src/test.rs | 12 | ||||
-rw-r--r-- | testing/geckodriver/src/tests/profile.zip | bin | 0 -> 444 bytes |
10 files changed, 4572 insertions, 0 deletions
diff --git a/testing/geckodriver/src/android.rs b/testing/geckodriver/src/android.rs new file mode 100644 index 0000000000..6aaf58ec6b --- /dev/null +++ b/testing/geckodriver/src/android.rs @@ -0,0 +1,470 @@ +use crate::capabilities::AndroidOptions; +use mozdevice::{AndroidStorage, Device, Host}; +use mozprofile::profile::Profile; +use serde::Serialize; +use serde_yaml::{Mapping, Value}; +use std::fmt; +use std::io; +use std::path::PathBuf; +use std::time; +use webdriver::error::{ErrorStatus, WebDriverError}; + +// TODO: avoid port clashes across GeckoView-vehicles. +// For now, we always use target port 2829, leading to issues like bug 1533704. +const TARGET_PORT: u16 = 2829; + +const CONFIG_FILE_HEADING: &str = r#"## GeckoView configuration YAML +## +## Auto-generated by geckodriver. +## See https://mozilla.github.io/geckoview/consumer/docs/automation. +"#; + +pub type Result<T> = std::result::Result<T, AndroidError>; + +#[derive(Debug)] +pub enum AndroidError { + ActivityNotFound(String), + Device(mozdevice::DeviceError), + IO(io::Error), + PackageNotFound(String), + Serde(serde_yaml::Error), +} + +impl fmt::Display for AndroidError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + AndroidError::ActivityNotFound(ref package) => { + write!(f, "Activity for package '{}' not found", package) + } + AndroidError::Device(ref message) => message.fmt(f), + AndroidError::IO(ref message) => message.fmt(f), + AndroidError::PackageNotFound(ref package) => { + write!(f, "Package '{}' not found", package) + } + AndroidError::Serde(ref message) => message.fmt(f), + } + } +} + +impl From<io::Error> for AndroidError { + fn from(value: io::Error) -> AndroidError { + AndroidError::IO(value) + } +} + +impl From<mozdevice::DeviceError> for AndroidError { + fn from(value: mozdevice::DeviceError) -> AndroidError { + AndroidError::Device(value) + } +} + +impl From<serde_yaml::Error> for AndroidError { + fn from(value: serde_yaml::Error) -> AndroidError { + AndroidError::Serde(value) + } +} + +impl From<AndroidError> for WebDriverError { + fn from(value: AndroidError) -> WebDriverError { + WebDriverError::new(ErrorStatus::UnknownError, value.to_string()) + } +} + +/// A remote Gecko instance. +/// +/// Host refers to the device running `geckodriver`. Target refers to the +/// Android device running Gecko in a GeckoView-based vehicle. +#[derive(Debug)] +pub struct AndroidProcess { + pub device: Device, + pub package: String, + pub activity: String, +} + +impl AndroidProcess { + pub fn new( + device: Device, + package: String, + activity: String, + ) -> mozdevice::Result<AndroidProcess> { + Ok(AndroidProcess { + device, + package, + activity, + }) + } +} + +#[derive(Debug)] +pub struct AndroidHandler { + pub config: PathBuf, + pub options: AndroidOptions, + pub process: AndroidProcess, + pub profile: PathBuf, + pub test_root: PathBuf, + + // For port forwarding host => target + pub host_port: u16, + pub target_port: u16, +} + +impl Drop for AndroidHandler { + fn drop(&mut self) { + // Try to clean up various settings + let clear_command = format!("am clear-debug-app {}", self.process.package); + match self + .process + .device + .execute_host_shell_command(&clear_command) + { + Ok(_) => debug!("Disabled reading from configuration file"), + Err(e) => error!("Failed disabling from configuration file: {}", e), + } + + match self.process.device.remove(&self.config) { + Ok(_) => debug!("Deleted GeckoView configuration file"), + Err(e) => error!("Failed deleting GeckoView configuration file: {}", e), + } + + match self.process.device.kill_forward_port(self.host_port) { + Ok(_) => debug!( + "Android port forward ({} -> {}) stopped", + &self.host_port, &self.target_port + ), + Err(e) => error!( + "Android port forward ({} -> {}) failed to stop: {}", + &self.host_port, &self.target_port, e + ), + } + } +} + +impl AndroidHandler { + pub fn new(options: &AndroidOptions, host_port: u16) -> Result<AndroidHandler> { + // We need to push profile.pathbuf to a safe space on the device. + // Make it per-Android package to avoid clashes and confusion. + // This naming scheme follows GeckoView's configuration file naming scheme, + // see bug 1533385. + + let host = Host { + host: None, + port: None, + read_timeout: Some(time::Duration::from_millis(5000)), + write_timeout: Some(time::Duration::from_millis(5000)), + }; + + let mut device = host.device_or_default(options.device_serial.as_ref(), options.storage)?; + + // Set up port forward. Port forwarding will be torn down, if possible, + device.forward_port(host_port, TARGET_PORT)?; + debug!( + "Android port forward ({} -> {}) started", + host_port, TARGET_PORT + ); + + let test_root = match device.storage { + AndroidStorage::App => { + device.run_as_package = Some(options.package.to_owned()); + let mut buf = PathBuf::from("/data/data"); + buf.push(&options.package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => PathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => PathBuf::from("/mnt/sdcard/test_root"), + }; + + debug!( + "Connecting: options={:?}, storage={:?}) test_root={}, run_as_package={:?}", + options, + device.storage, + test_root.display(), + device.run_as_package + ); + + let mut profile = test_root.clone(); + profile.push(format!("{}-geckodriver-profile", &options.package)); + + // Check if the specified package is installed + let response = + device.execute_host_shell_command(&format!("pm list packages {}", &options.package))?; + let packages = response + .trim() + .split_terminator('\n') + .filter(|line| line.starts_with("package:")) + .map(|line| line.rsplit(':').next().expect("Package name found")) + .collect::<Vec<&str>>(); + if !packages.contains(&options.package.as_str()) { + return Err(AndroidError::PackageNotFound(options.package.clone())); + } + + let config = PathBuf::from(format!( + "/data/local/tmp/{}-geckoview-config.yaml", + &options.package + )); + + // If activity hasn't been specified default to the main activity of the package + let activity = match options.activity { + Some(ref activity) => activity.clone(), + None => { + let response = device.execute_host_shell_command(&format!( + "cmd package resolve-activity --brief {}", + &options.package + ))?; + let activities = response + .split_terminator('\n') + .filter(|line| line.starts_with(&options.package)) + .map(|line| line.rsplit('/').next().unwrap()) + .collect::<Vec<&str>>(); + if activities.is_empty() { + return Err(AndroidError::ActivityNotFound(options.package.clone())); + } + + activities[0].to_owned() + } + }; + + let process = AndroidProcess::new(device, options.package.clone(), activity)?; + + Ok(AndroidHandler { + options: options.clone(), + config, + process, + profile, + test_root, + host_port, + target_port: TARGET_PORT, + }) + } + + pub fn generate_config_file<I, K, V>(&self, envs: I) -> Result<String> + where + I: IntoIterator<Item = (K, V)>, + K: ToString, + V: ToString, + { + // To configure GeckoView, we use the automation techniques documented at + // https://mozilla.github.io/geckoview/consumer/docs/automation. + #[derive(Serialize, Deserialize, PartialEq, Debug)] + pub struct Config { + pub env: Mapping, + pub args: Value, + } + + // TODO: Allow to write custom arguments and preferences from moz:firefoxOptions + let mut config = Config { + args: Value::Sequence(vec![ + Value::String("--marionette".into()), + Value::String("--profile".into()), + Value::String(self.profile.display().to_string()), + ]), + env: Mapping::new(), + }; + + for (key, value) in envs { + config.env.insert( + Value::String(key.to_string()), + Value::String(value.to_string()), + ); + } + + config.env.insert( + Value::String("MOZ_CRASHREPORTER".to_owned()), + Value::String("1".to_owned()), + ); + config.env.insert( + Value::String("MOZ_CRASHREPORTER_NO_REPORT".to_owned()), + Value::String("1".to_owned()), + ); + config.env.insert( + Value::String("MOZ_CRASHREPORTER_SHUTDOWN".to_owned()), + Value::String("1".to_owned()), + ); + + let mut contents: Vec<String> = vec![CONFIG_FILE_HEADING.to_owned()]; + contents.push(serde_yaml::to_string(&config)?); + + Ok(contents.concat()) + } + + pub fn prepare<I, K, V>(&self, profile: &Profile, env: I) -> Result<()> + where + I: IntoIterator<Item = (K, V)>, + K: ToString, + V: ToString, + { + self.process.device.clear_app_data(&self.process.package)?; + + // These permissions, at least, are required to read profiles in /mnt/sdcard. + for perm in &["READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] { + self.process.device.execute_host_shell_command(&format!( + "pm grant {} android.permission.{}", + &self.process.package, perm + ))?; + } + + // Make sure to create the test root. + self.process.device.create_dir(&self.test_root)?; + self.process.device.chmod(&self.test_root, "777", true)?; + + // Replace the profile + self.process.device.remove(&self.profile)?; + self.process + .device + .push_dir(&profile.path, &self.profile, 0o777)?; + + let contents = self.generate_config_file(env)?; + debug!("Content of generated GeckoView config file:\n{}", contents); + let reader = &mut io::BufReader::new(contents.as_bytes()); + + debug!( + "Pushing GeckoView configuration file to {}", + self.config.display() + ); + self.process.device.push(reader, &self.config, 0o777)?; + + // Tell GeckoView to read configuration even when `android:debuggable="false"`. + self.process.device.execute_host_shell_command(&format!( + "am set-debug-app --persistent {}", + self.process.package + ))?; + + Ok(()) + } + + pub fn launch(&self) -> Result<()> { + // TODO: Remove the usage of intent arguments once Fennec is no longer + // supported. Packages which are using GeckoView always read the arguments + // via the YAML configuration file. + let mut intent_arguments = self + .options + .intent_arguments + .clone() + .unwrap_or_else(|| Vec::with_capacity(3)); + intent_arguments.push("--es".to_owned()); + intent_arguments.push("args".to_owned()); + intent_arguments.push(format!("--marionette --profile {}", self.profile.display())); + + debug!( + "Launching {}/{}", + self.process.package, self.process.activity + ); + self.process + .device + .launch( + &self.process.package, + &self.process.activity, + &intent_arguments, + ) + .map_err(|e| { + let message = format!( + "Could not launch Android {}/{}: {}", + self.process.package, self.process.activity, e + ); + mozdevice::DeviceError::Adb(message) + })?; + + Ok(()) + } + + pub fn force_stop(&self) -> Result<()> { + debug!( + "Force stopping the Android package: {}", + &self.process.package + ); + self.process.device.force_stop(&self.process.package)?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + // To successfully run those tests the geckoview_example package needs to + // be installed on the device or emulator. After setting up the build + // environment (https://mzl.la/3muLv5M), the following mach commands have to + // be executed: + // + // $ ./mach build && ./mach install + // + // Currently the mozdevice API is not safe for multiple requests at the same + // time. It is recommended to run each of the unit tests on its own. Also adb + // specific tests cannot be run in CI yet. To check those locally, also run + // the ignored tests. + // + // Use the following command to accomplish that: + // + // $ cargo test -- --ignored --test-threads=1 + + use crate::android::AndroidHandler; + use crate::capabilities::AndroidOptions; + use mozdevice::{AndroidStorage, AndroidStorageInput}; + use std::path::PathBuf; + + fn run_handler_storage_test(package: &str, storage: AndroidStorageInput) { + let options = AndroidOptions::new(package.to_owned(), storage); + let handler = AndroidHandler::new(&options, 4242).expect("has valid Android handler"); + + assert_eq!(handler.options, options); + assert_eq!(handler.process.package, package); + + let expected_config_path = PathBuf::from(format!( + "/data/local/tmp/{}-geckoview-config.yaml", + &package + )); + assert_eq!(handler.config, expected_config_path); + + if handler.process.device.storage == AndroidStorage::App { + assert_eq!( + handler.process.device.run_as_package, + Some(package.to_owned()) + ); + } else { + assert_eq!(handler.process.device.run_as_package, None); + } + + let test_root = match handler.process.device.storage { + AndroidStorage::App => { + let mut buf = PathBuf::from("/data/data"); + buf.push(&package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => PathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => PathBuf::from("/mnt/sdcard/test_root"), + }; + assert_eq!(handler.test_root, test_root); + + let mut profile = test_root.clone(); + profile.push(format!("{}-geckodriver-profile", &package)); + assert_eq!(handler.profile, profile); + } + + #[test] + #[ignore] + fn android_handler_storage_as_app() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(&package, AndroidStorageInput::App); + } + + #[test] + #[ignore] + fn android_handler_storage_as_auto() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Auto); + } + + #[test] + #[ignore] + fn android_handler_storage_as_internal() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Internal); + } + + #[test] + #[ignore] + fn android_handler_storage_as_sdcard() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Sdcard); + } +} diff --git a/testing/geckodriver/src/build.rs b/testing/geckodriver/src/build.rs new file mode 100644 index 0000000000..7ba3144755 --- /dev/null +++ b/testing/geckodriver/src/build.rs @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde_json::Value; +use std::fmt; + +include!(concat!(env!("OUT_DIR"), "/build-info.rs")); + +pub struct BuildInfo; + +impl BuildInfo { + pub fn version() -> &'static str { + crate_version!() + } + + pub fn hash() -> Option<&'static str> { + COMMIT_HASH + } + + pub fn date() -> Option<&'static str> { + COMMIT_DATE + } +} + +impl fmt::Display for BuildInfo { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", BuildInfo::version())?; + match (BuildInfo::hash(), BuildInfo::date()) { + (Some(hash), Some(date)) => write!(f, " ({} {})", hash, date)?, + (Some(hash), None) => write!(f, " ({})", hash)?, + _ => {} + } + Ok(()) + } +} + +// TODO(Henrik): Change into From +//std::convert::From<&str>` is not implemented for `rustc_serialize::json::Json +impl Into<Value> for BuildInfo { + fn into(self) -> Value { + Value::String(BuildInfo::version().to_string()) + } +} + +/// Returns build-time information about geckodriver. +pub fn build_info() -> BuildInfo { + BuildInfo {} +} diff --git a/testing/geckodriver/src/capabilities.rs b/testing/geckodriver/src/capabilities.rs new file mode 100644 index 0000000000..e21651ea61 --- /dev/null +++ b/testing/geckodriver/src/capabilities.rs @@ -0,0 +1,1088 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::command::LogOptions; +use crate::logging::Level; +use base64; +use mozdevice::AndroidStorageInput; +use mozprofile::preferences::Pref; +use mozprofile::profile::Profile; +use mozrunner::runner::platform::firefox_default_path; +use mozversion::{self, firefox_binary_version, firefox_version, Version}; +use regex::bytes::Regex; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::default::Default; +use std::fmt::{self, Display}; +use std::fs; +use std::io; +use std::io::BufWriter; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::str::{self, FromStr}; +use webdriver::capabilities::{BrowserCapabilities, Capabilities}; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; +use zip; + +#[derive(Clone, Debug)] +enum VersionError { + VersionError(mozversion::Error), + MissingBinary, +} + +impl From<mozversion::Error> for VersionError { + fn from(err: mozversion::Error) -> VersionError { + VersionError::VersionError(err) + } +} + +impl Display for VersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + VersionError::VersionError(ref x) => x.fmt(f), + VersionError::MissingBinary => "No binary provided".fmt(f), + } + } +} + +impl From<VersionError> for WebDriverError { + fn from(err: VersionError) -> WebDriverError { + WebDriverError::new(ErrorStatus::SessionNotCreated, err.to_string()) + } +} + +/// Provides matching of `moz:firefoxOptions` and resolutionnized of which Firefox +/// binary to use. +/// +/// `FirefoxCapabilities` is constructed with the fallback binary, should +/// `moz:firefoxOptions` not contain a binary entry. This may either be the +/// system Firefox installation or an override, for example given to the +/// `--binary` flag of geckodriver. +pub struct FirefoxCapabilities<'a> { + pub chosen_binary: Option<PathBuf>, + fallback_binary: Option<&'a PathBuf>, + version_cache: BTreeMap<PathBuf, Result<Version, VersionError>>, +} + +impl<'a> FirefoxCapabilities<'a> { + pub fn new(fallback_binary: Option<&'a PathBuf>) -> FirefoxCapabilities<'a> { + FirefoxCapabilities { + chosen_binary: None, + fallback_binary, + version_cache: BTreeMap::new(), + } + } + + fn set_binary(&mut self, capabilities: &Map<String, Value>) { + self.chosen_binary = capabilities + .get("moz:firefoxOptions") + .and_then(|x| x.get("binary")) + .and_then(|x| x.as_str()) + .map(PathBuf::from) + .or_else(|| self.fallback_binary.cloned()) + .or_else(firefox_default_path); + } + + fn version(&mut self, binary: Option<&Path>) -> Result<Version, VersionError> { + if let Some(binary) = binary { + if let Some(cache_value) = self.version_cache.get(binary) { + return cache_value.clone(); + } + let rv = self + .version_from_ini(binary) + .or_else(|_| self.version_from_binary(binary)); + if let Ok(ref version) = rv { + debug!("Found version {}", version); + } else { + debug!("Failed to get binary version"); + } + self.version_cache.insert(binary.to_path_buf(), rv.clone()); + rv + } else { + Err(VersionError::MissingBinary) + } + } + + fn version_from_ini(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from ini files"); + let version = firefox_version(binary)?; + if let Some(version_string) = version.version_string { + Version::from_str(&version_string).map_err(|err| err.into()) + } else { + Err(VersionError::VersionError( + mozversion::Error::MetadataError("Missing version string".into()), + )) + } + } + + fn version_from_binary(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from binary"); + Ok(firefox_binary_version(binary)?) + } +} + +impl<'a> BrowserCapabilities for FirefoxCapabilities<'a> { + fn init(&mut self, capabilities: &Capabilities) { + self.set_binary(capabilities); + } + + fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(Some("firefox".into())) + } + + fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + let binary = self.chosen_binary.clone(); + self.version(binary.as_ref().map(|x| x.as_ref())) + .map_err(|err| err.into()) + .map(|x| Some(x.to_string())) + } + + fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(if cfg!(target_os = "windows") { + Some("windows".into()) + } else if cfg!(target_os = "macos") { + Some("mac".into()) + } else if cfg!(target_os = "linux") { + Some("linux".into()) + } else { + None + }) + } + + fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn compare_browser_version( + &mut self, + version: &str, + comparison: &str, + ) -> WebDriverResult<bool> { + Version::from_str(version) + .map_err(|err| VersionError::from(err))? + .matches(comparison) + .map_err(|err| VersionError::from(err).into()) + } + + fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()> { + if !name.starts_with("moz:") { + return Ok(()); + } + match name { + "moz:firefoxOptions" => { + let data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "moz:firefoxOptions is not an object" + ); + for (key, value) in data.iter() { + match &**key { + "androidActivity" + | "androidDeviceSerial" + | "androidPackage" + | "profile" => { + if !value.is_string() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "androidIntentArguments" | "args" => { + if !try_opt!( + value.as_array(), + ErrorStatus::InvalidArgument, + format!("{} is not an array", &**key) + ) + .iter() + .all(|value| value.is_string()) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} entry is not a string", &**key), + )); + } + } + "binary" => { + if let Some(binary) = value.as_str() { + if !data.contains_key("androidPackage") + && self.version(Some(Path::new(binary))).is_err() + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a Firefox executable", &**key), + )); + } + } else { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "env" => { + let env_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "env value is not an object" + ); + if !env_data.values().all(Value::is_string) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Environment values were not all strings", + )); + } + } + "log" => { + let log_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "log value is not an object" + ); + for (log_key, log_value) in log_data.iter() { + match &**log_key { + "level" => { + let level = try_opt!( + log_value.as_str(), + ErrorStatus::InvalidArgument, + "log level is not a string" + ); + if Level::from_str(level).is_err() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Not a valid log level: {}", level), + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid log field {}", x), + )) + } + } + } + } + "prefs" => { + let prefs_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "prefs value is not an object" + ); + let is_pref_value_type = |x: &Value| { + x.is_string() || x.is_i64() || x.is_u64() || x.is_boolean() + }; + if !prefs_data.values().all(is_pref_value_type) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Preference values not all string or integer or boolean", + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid moz:firefoxOptions field {}", x), + )) + } + } + } + } + "moz:useNonSpecCompliantPointerOrigin" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:useNonSpecCompliantPointerOrigin is not a boolean", + )); + } + } + "moz:webdriverClick" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:webdriverClick is not a boolean", + )); + } + } + "moz:debuggerAddress" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:debuggerAddress is not a boolean", + )); + } + } + _ => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Unrecognised option {}", name), + )) + } + } + Ok(()) + } + + fn accept_custom(&mut self, _: &str, _: &Value, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } +} + +/// Android-specific options in the `moz:firefoxOptions` struct. +/// These map to "androidCamelCase", following [chromedriver's Android-specific +/// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android). +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AndroidOptions { + pub activity: Option<String>, + pub device_serial: Option<String>, + pub intent_arguments: Option<Vec<String>>, + pub package: String, + pub storage: AndroidStorageInput, +} + +impl AndroidOptions { + pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions { + AndroidOptions { + package, + storage, + ..Default::default() + } + } +} + +/// Rust representation of `moz:firefoxOptions`. +/// +/// Calling `FirefoxOptions::from_capabilities(binary, capabilities)` causes +/// the encoded profile, the binary arguments, log settings, and additional +/// preferences to be checked and unmarshaled from the `moz:firefoxOptions` +/// JSON Object into a Rust representation. +#[derive(Default, Debug)] +pub struct FirefoxOptions { + pub binary: Option<PathBuf>, + pub profile: Option<Profile>, + pub args: Option<Vec<String>>, + pub env: Option<Vec<(String, String)>>, + pub log: LogOptions, + pub prefs: Vec<(String, Pref)>, + pub android: Option<AndroidOptions>, +} + +impl FirefoxOptions { + pub fn new() -> FirefoxOptions { + Default::default() + } + + pub fn from_capabilities( + binary_path: Option<PathBuf>, + android_storage: AndroidStorageInput, + matched: &mut Capabilities, + ) -> WebDriverResult<FirefoxOptions> { + let mut rv = FirefoxOptions::new(); + rv.binary = binary_path; + + if let Some(json) = matched.remove("moz:firefoxOptions") { + let options = json.as_object().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "'moz:firefoxOptions' \ + capability is not an object", + ) + })?; + + rv.android = FirefoxOptions::load_android(android_storage, &options)?; + rv.args = FirefoxOptions::load_args(&options)?; + rv.env = FirefoxOptions::load_env(&options)?; + rv.log = FirefoxOptions::load_log(&options)?; + rv.prefs = FirefoxOptions::load_prefs(&options)?; + rv.profile = FirefoxOptions::load_profile(&options)?; + } + + if let Some(json) = matched.remove("moz:debuggerAddress") { + let use_web_socket = json.as_bool().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:debuggerAddress is not a boolean", + ) + })?; + + if use_web_socket { + let mut remote_args = Vec::new(); + remote_args.push("--remote-debugging-port".to_owned()); + remote_args.push("0".to_owned()); + + if let Some(ref mut args) = rv.args { + args.append(&mut remote_args); + } else { + rv.args = Some(remote_args); + } + + // Force Fission disabled until Remote Agent is compatible, + // and preference hasn't been already set + let has_fission_pref = rv.prefs.iter().find(|&x| x.0 == "fission.autostart"); + if has_fission_pref.is_none() { + rv.prefs + .push(("fission.autostart".to_owned(), Pref::new(false))); + } + } + } + + Ok(rv) + } + + fn load_profile(options: &Capabilities) -> WebDriverResult<Option<Profile>> { + if let Some(profile_json) = options.get("profile") { + let profile_base64 = profile_json.as_str().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Profile is not a string") + })?; + let profile_zip = &*base64::decode(profile_base64)?; + + // Create an emtpy profile directory + let profile = Profile::new()?; + unzip_buffer( + profile_zip, + profile + .temp_dir + .as_ref() + .expect("Profile doesn't have a path") + .path(), + )?; + + Ok(Some(profile)) + } else { + Ok(None) + } + } + + fn load_args(options: &Capabilities) -> WebDriverResult<Option<Vec<String>>> { + if let Some(args_json) = options.get("args") { + let args_array = args_json.as_array().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Arguments were not an \ + array", + ) + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Arguments entries were not all strings", + ) + })?; + Ok(Some(args)) + } else { + Ok(None) + } + } + + pub fn load_env(options: &Capabilities) -> WebDriverResult<Option<Vec<(String, String)>>> { + if let Some(env_data) = options.get("env") { + let env = env_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Env was not an object") + })?; + let mut rv = Vec::with_capacity(env.len()); + for (key, value) in env.iter() { + rv.push(( + key.clone(), + value + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Env value is not a string", + ) + })? + .to_string(), + )); + } + Ok(Some(rv)) + } else { + Ok(None) + } + } + + fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> { + if let Some(json) = options.get("log") { + let log = json.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log section is not an object") + })?; + + let level = match log.get("level") { + Some(json) => { + let s = json.as_str().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Log level is not a string", + ) + })?; + Some(Level::from_str(s).ok().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log level is unknown") + })?) + } + None => None, + }; + + Ok(LogOptions { level }) + } else { + Ok(Default::default()) + } + } + + pub fn load_prefs(options: &Capabilities) -> WebDriverResult<Vec<(String, Pref)>> { + if let Some(prefs_data) = options.get("prefs") { + let prefs = prefs_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Prefs were not an object") + })?; + let mut rv = Vec::with_capacity(prefs.len()); + for (key, value) in prefs.iter() { + rv.push((key.clone(), pref_from_json(value)?)); + } + Ok(rv) + } else { + Ok(vec![]) + } + } + + pub fn load_android( + storage: AndroidStorageInput, + options: &Capabilities, + ) -> WebDriverResult<Option<AndroidOptions>> { + if let Some(package_json) = options.get("androidPackage") { + let package = package_json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage is not a string", + ) + })? + .to_owned(); + + // https://developer.android.com/studio/build/application-id + let package_regexp = + Regex::new(r#"^([a-zA-Z][a-zA-Z0-9_]*\.){1,}([a-zA-Z][a-zA-Z0-9_]*)$"#).unwrap(); + if !package_regexp.is_match(package.as_bytes()) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Not a valid androidPackage name", + )); + } + + let mut android = AndroidOptions::new(package, storage); + + android.activity = match options.get("androidActivity") { + Some(json) => { + let activity = json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity is not a string", + ) + })? + .to_owned(); + + if activity.contains("/") { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity should not contain '/", + )); + } + + Some(activity) + } + None => None, + }; + + android.device_serial = match options.get("androidDeviceSerial") { + Some(json) => Some( + json.as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidDeviceSerial is not a string", + ) + })? + .to_owned(), + ), + None => None, + }; + + android.intent_arguments = match options.get("androidIntentArguments") { + Some(json) => { + let args_array = json.as_array().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments is not an array", + ) + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments entries are not all strings", + ) + })?; + + Some(args) + } + None => None, + }; + + Ok(Some(android)) + } else { + Ok(None) + } + } +} + +fn pref_from_json(value: &Value) -> WebDriverResult<Pref> { + match *value { + Value::String(ref x) => Ok(Pref::new(x.clone())), + Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())), + Value::Bool(x) => Ok(Pref::new(x)), + _ => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Could not convert pref value to string, boolean, or integer", + )), + } +} + +fn unzip_buffer(buf: &[u8], dest_dir: &Path) -> WebDriverResult<()> { + let reader = Cursor::new(buf); + let mut zip = zip::ZipArchive::new(reader) + .map_err(|_| WebDriverError::new(ErrorStatus::UnknownError, "Failed to unzip profile"))?; + + for i in 0..zip.len() { + let mut file = zip.by_index(i).map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Processing profile zip file failed", + ) + })?; + let unzip_path = { + let name = file.name(); + let is_dir = name.ends_with('/'); + let rel_path = Path::new(name); + let dest_path = dest_dir.join(rel_path); + + { + let create_dir = if is_dir { + Some(dest_path.as_path()) + } else { + dest_path.parent() + }; + if let Some(dir) = create_dir { + if !dir.exists() { + debug!("Creating profile directory tree {}", dir.to_string_lossy()); + fs::create_dir_all(dir)?; + } + } + } + + if is_dir { + None + } else { + Some(dest_path) + } + }; + + if let Some(unzip_path) = unzip_path { + debug!("Extracting profile to {}", unzip_path.to_string_lossy()); + let dest = fs::File::create(unzip_path)?; + if file.size() > 0 { + let mut writer = BufWriter::new(dest); + io::copy(&mut file, &mut writer)?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + extern crate mozprofile; + + use self::mozprofile::preferences::Pref; + use super::*; + use crate::marionette::MarionetteHandler; + use mozdevice::AndroidStorageInput; + use serde_json::json; + use std::default::Default; + use std::fs::File; + use std::io::Read; + + use webdriver::capabilities::Capabilities; + + fn example_profile() -> Value { + let mut profile_data = Vec::with_capacity(1024); + let mut profile = File::open("src/tests/profile.zip").unwrap(); + profile.read_to_end(&mut profile_data).unwrap(); + Value::String(base64::encode(&profile_data)) + } + + fn make_options(firefox_opts: Capabilities) -> WebDriverResult<FirefoxOptions> { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps) + } + + #[test] + fn fx_options_default() { + let opts = FirefoxOptions::new(); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + // Profile doesn't support PartialEq + // assert_eq!(opts.profile, None); + } + + #[test] + fn fx_options_from_capabilities_no_binary_and_caps() { + let mut caps = Capabilities::new(); + + let opts = + FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps).unwrap(); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_binary_and_caps() { + let mut caps = Capabilities::new(); + caps.insert( + "moz:firefoxOptions".into(), + Value::Object(Capabilities::new()), + ); + + let binary = PathBuf::from("foo"); + + let opts = FirefoxOptions::from_capabilities( + Some(binary.clone()), + AndroidStorageInput::Auto, + &mut caps, + ) + .unwrap(); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, Some(binary)); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_not_set() { + let mut caps = Capabilities::new(); + + let opts = FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_false() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(false)); + + let opts = FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for remote protocol unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_true() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(true)); + + let opts = FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter + .find(|&arg| arg == &"--remote-debugging-port".to_owned()) + .is_some()); + assert_eq!(iter.next(), Some(&"0".to_owned())); + } else { + assert!(false, "CLI arguments for remote protocol not found"); + } + + assert!(opts + .prefs + .iter() + .any(|pref| pref == &("fission.autostart".to_owned(), Pref::new(false)))); + } + + #[test] + fn fx_options_from_capabilities_with_invalid_caps() { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), json!(42)); + + FirefoxOptions::from_capabilities(None, AndroidStorageInput::Auto, &mut caps) + .expect_err("Firefox options need to be of type object"); + } + + #[test] + fn fx_options_android_no_package() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidAvtivity".into(), json!("foo")); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + assert_eq!(opts.android, None); + } + + #[test] + fn fx_options_android_package_valid_value() { + for value in ["foo.bar", "foo.bar.cheese.is.good", "Foo.Bar_9"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + assert_eq!( + opts.android, + Some(AndroidOptions::new( + value.to_string(), + AndroidStorageInput::Auto + )) + ); + } + } + + #[test] + fn fx_options_android_package_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(42)); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_package_invalid_value() { + for value in ["../foo", "\\foo\n", "foo", "_foo", "0foo"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + make_options(firefox_opts).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_android_activity_valid_value() { + for value in ["cheese", "Cheese_9"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!(value)); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + let android_opts = AndroidOptions { + package: "foo.bar".to_owned(), + activity: Some(value.to_string()), + ..Default::default() + }; + assert_eq!(opts.android, Some(android_opts)); + } + } + + #[test] + fn fx_options_android_activity_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!(42)); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_activity_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo.bar/cheese")); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_device_serial() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!("cheese")); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + let android_opts = AndroidOptions { + package: "foo.bar".to_owned(), + device_serial: Some("cheese".to_owned()), + ..Default::default() + }; + assert_eq!(opts.android, Some(android_opts)); + } + + #[test] + fn fx_options_android_serial_invalid() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!(42)); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", "ipsum"])); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + let android_opts = AndroidOptions { + package: "foo.bar".to_owned(), + intent_arguments: Some(vec!["lorem".to_owned(), "ipsum".to_owned()]), + ..Default::default() + }; + assert_eq!(opts.android, Some(android_opts)); + } + + #[test] + fn fx_options_android_intent_arguments_no_array() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(42)); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", 42])); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY_A".into(), Value::String("test_value_a".into())); + env.insert("TEST_KEY_B".into(), Value::String("test_value_b".into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + let mut opts = make_options(firefox_opts).expect("valid firefox options"); + for sorted in opts.env.iter_mut() { + sorted.sort() + } + assert_eq!( + opts.env, + Some(vec![ + ("TEST_KEY_A".into(), "test_value_a".into()), + ("TEST_KEY_B".into(), "test_value_b".into()), + ]) + ); + } + + #[test] + fn fx_options_env_invalid_container() { + let env = Value::Number(1.into()); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env_invalid_value() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY".into(), Value::Number(1.into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + make_options(firefox_opts).expect_err("invalid firefox options"); + } + + #[test] + fn test_profile() { + let encoded_profile = example_profile(); + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("profile".into(), encoded_profile); + + let opts = make_options(firefox_opts).expect("valid firefox options"); + let mut profile = opts.profile.expect("valid firefox profile"); + let prefs = profile.user_prefs().expect("valid preferences"); + + println!("{:#?}", prefs.prefs); + + assert_eq!( + prefs.get("startup.homepage_welcome_url"), + Some(&Pref::new("data:text/html,PASS")) + ); + } + + #[test] + fn test_prefs() { + let encoded_profile = example_profile(); + let mut prefs: Map<String, Value> = Map::new(); + prefs.insert( + "browser.display.background_color".into(), + Value::String("#00ff00".into()), + ); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("profile".into(), encoded_profile); + firefox_opts.insert("prefs".into(), Value::Object(prefs)); + + let opts = make_options(firefox_opts).expect("valid profile and prefs"); + let mut profile = opts.profile.expect("valid firefox profile"); + + let handler = MarionetteHandler::new(Default::default()); + handler + .set_prefs(2828, &mut profile, true, opts.prefs) + .expect("set preferences"); + + let prefs_set = profile.user_prefs().expect("valid user preferences"); + println!("{:#?}", prefs_set.prefs); + + assert_eq!( + prefs_set.get("startup.homepage_welcome_url"), + Some(&Pref::new("data:text/html,PASS")) + ); + assert_eq!( + prefs_set.get("browser.display.background_color"), + Some(&Pref::new("#00ff00")) + ); + assert_eq!(prefs_set.get("marionette.port"), Some(&Pref::new(2828))); + } +} diff --git a/testing/geckodriver/src/command.rs b/testing/geckodriver/src/command.rs new file mode 100644 index 0000000000..f5bc27ad81 --- /dev/null +++ b/testing/geckodriver/src/command.rs @@ -0,0 +1,342 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::logging; +use base64; +use hyper::Method; +use serde::de::{self, Deserialize, Deserializer}; +use serde_json::{self, Value}; +use std::env; +use std::fs::File; +use std::io::prelude::*; +use uuid::Uuid; +use webdriver::command::{WebDriverCommand, WebDriverExtensionCommand}; +use webdriver::error::WebDriverResult; +use webdriver::httpapi::WebDriverExtensionRoute; +use webdriver::Parameters; + +pub const CHROME_ELEMENT_KEY: &str = "chromeelement-9fc5-4b51-a3c8-01716eedeb04"; + +pub fn extension_routes() -> Vec<(Method, &'static str, GeckoExtensionRoute)> { + return vec![ + ( + Method::GET, + "/session/{sessionId}/moz/context", + GeckoExtensionRoute::GetContext, + ), + ( + Method::POST, + "/session/{sessionId}/moz/context", + GeckoExtensionRoute::SetContext, + ), + ( + Method::POST, + "/session/{sessionId}/moz/addon/install", + GeckoExtensionRoute::InstallAddon, + ), + ( + Method::POST, + "/session/{sessionId}/moz/addon/uninstall", + GeckoExtensionRoute::UninstallAddon, + ), + ( + Method::GET, + "/session/{sessionId}/moz/screenshot/full", + GeckoExtensionRoute::TakeFullScreenshot, + ), + ]; +} + +#[derive(Clone, PartialEq)] +pub enum GeckoExtensionRoute { + GetContext, + SetContext, + InstallAddon, + UninstallAddon, + TakeFullScreenshot, +} + +impl WebDriverExtensionRoute for GeckoExtensionRoute { + type Command = GeckoExtensionCommand; + + fn command( + &self, + _params: &Parameters, + body_data: &Value, + ) -> WebDriverResult<WebDriverCommand<GeckoExtensionCommand>> { + use self::GeckoExtensionRoute::*; + + let command = match *self { + GetContext => GeckoExtensionCommand::GetContext, + SetContext => { + GeckoExtensionCommand::SetContext(serde_json::from_value(body_data.clone())?) + } + InstallAddon => { + GeckoExtensionCommand::InstallAddon(serde_json::from_value(body_data.clone())?) + } + UninstallAddon => { + GeckoExtensionCommand::UninstallAddon(serde_json::from_value(body_data.clone())?) + } + TakeFullScreenshot => GeckoExtensionCommand::TakeFullScreenshot, + }; + + Ok(WebDriverCommand::Extension(command)) + } +} + +#[derive(Clone)] +pub enum GeckoExtensionCommand { + GetContext, + SetContext(GeckoContextParameters), + InstallAddon(AddonInstallParameters), + UninstallAddon(AddonUninstallParameters), + TakeFullScreenshot, +} + +impl WebDriverExtensionCommand for GeckoExtensionCommand { + fn parameters_json(&self) -> Option<Value> { + use self::GeckoExtensionCommand::*; + match self { + GetContext => None, + InstallAddon(x) => Some(serde_json::to_value(x).unwrap()), + SetContext(x) => Some(serde_json::to_value(x).unwrap()), + UninstallAddon(x) => Some(serde_json::to_value(x).unwrap()), + TakeFullScreenshot => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct AddonInstallParameters { + pub path: String, + pub temporary: Option<bool>, +} + +impl<'de> Deserialize<'de> for AddonInstallParameters { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Base64 { + addon: String, + temporary: Option<bool>, + }; + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Path { + path: String, + temporary: Option<bool>, + }; + + #[derive(Debug, Deserialize)] + #[serde(untagged)] + enum Helper { + Base64(Base64), + Path(Path), + }; + + let params = match Helper::deserialize(deserializer)? { + Helper::Path(ref mut data) => AddonInstallParameters { + path: data.path.clone(), + temporary: data.temporary, + }, + Helper::Base64(ref mut data) => { + let content = base64::decode(&data.addon).map_err(de::Error::custom)?; + + let path = env::temp_dir() + .as_path() + .join(format!("addon-{}.xpi", Uuid::new_v4())); + let mut xpi_file = File::create(&path).map_err(de::Error::custom)?; + xpi_file + .write(content.as_slice()) + .map_err(de::Error::custom)?; + + let path = match path.to_str() { + Some(path) => path.to_string(), + None => return Err(de::Error::custom("could not write addon to file")), + }; + + AddonInstallParameters { + path, + temporary: data.temporary, + } + } + }; + + Ok(params) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AddonUninstallParameters { + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GeckoContext { + Content, + Chrome, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeckoContextParameters { + pub context: GeckoContext, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct XblLocatorParameters { + pub name: String, + pub value: String, +} + +#[derive(Default, Debug, PartialEq)] +pub struct LogOptions { + pub level: Option<logging::Level>, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::test::assert_de; + + #[test] + fn test_json_addon_install_parameters_invalid() { + assert!(serde_json::from_str::<AddonInstallParameters>("").is_err()); + assert!(serde_json::from_value::<AddonInstallParameters>(json!(null)).is_err()); + assert!(serde_json::from_value::<AddonInstallParameters>(json!({})).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_path_and_temporary() { + let params = AddonInstallParameters { + path: "/path/to.xpi".to_string(), + temporary: Some(true), + }; + assert_de(¶ms, json!({"path": "/path/to.xpi", "temporary": true})); + } + + #[test] + fn test_json_addon_install_parameters_with_path() { + let params = AddonInstallParameters { + path: "/path/to.xpi".to_string(), + temporary: None, + }; + assert_de(¶ms, json!({"path": "/path/to.xpi"})); + } + + #[test] + fn test_json_addon_install_parameters_with_path_invalid_type() { + let json = json!({"path": true, "temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_path_and_temporary_invalid_type() { + let json = json!({"path": "/path/to.xpi", "temporary": "foo"}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_addon() { + let json = json!({"addon": "aGVsbG8=", "temporary": true}); + let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap(); + + assert_eq!(data.temporary, Some(true)); + let mut file = File::open(data.path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "hello"); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_only() { + let json = json!({"addon": "aGVsbG8="}); + let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap(); + + assert_eq!(data.temporary, None); + let mut file = File::open(data.path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "hello"); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_invalid_type() { + let json = json!({"addon": true, "temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_and_temporary_invalid_type() { + let json = json!({"addon": "aGVsbG8=", "temporary": "foo"}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_install_parameters_with_temporary_only() { + let json = json!({"temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_both_path_and_addon() { + let json = json!({ + "path": "/path/to.xpi", + "addon": "aGVsbG8=", + "temporary": true, + }); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_uninstall_parameters_invalid() { + assert!(serde_json::from_str::<AddonUninstallParameters>("").is_err()); + assert!(serde_json::from_value::<AddonUninstallParameters>(json!(null)).is_err()); + assert!(serde_json::from_value::<AddonUninstallParameters>(json!({})).is_err()); + } + + #[test] + fn test_json_addon_uninstall_parameters() { + let params = AddonUninstallParameters { + id: "foo".to_string(), + }; + assert_de(¶ms, json!({"id": "foo"})); + } + + #[test] + fn test_json_addon_uninstall_parameters_id_invalid_type() { + let json = json!({"id": true}); + assert!(serde_json::from_value::<AddonUninstallParameters>(json).is_err()); + } + + #[test] + fn test_json_gecko_context_parameters_content() { + let params = GeckoContextParameters { + context: GeckoContext::Content, + }; + assert_de(¶ms, json!({"context": "content"})); + } + + #[test] + fn test_json_gecko_context_parameters_chrome() { + let params = GeckoContextParameters { + context: GeckoContext::Chrome, + }; + assert_de(¶ms, json!({"context": "chrome"})); + } + + #[test] + fn test_json_gecko_context_parameters_context_invalid() { + type P = GeckoContextParameters; + assert!(serde_json::from_value::<P>(json!({})).is_err()); + assert!(serde_json::from_value::<P>(json!({ "context": null })).is_err()); + assert!(serde_json::from_value::<P>(json!({"context": "foo"})).is_err()); + } +} diff --git a/testing/geckodriver/src/logging.rs b/testing/geckodriver/src/logging.rs new file mode 100644 index 0000000000..7721bb7770 --- /dev/null +++ b/testing/geckodriver/src/logging.rs @@ -0,0 +1,351 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Gecko-esque logger implementation for the [`log`] crate. +//! +//! The [`log`] crate provides a single logging API that abstracts over the +//! actual logging implementation. This module uses the logging API +//! to provide a log implementation that shares many aesthetical traits with +//! [Log.jsm] from Gecko. +//! +//! Using the [`error!`], [`warn!`], [`info!`], [`debug!`], and +//! [`trace!`] macros from `log` will output a timestamp field, followed by the +//! log level, and then the message. The fields are separated by a tab +//! character, making the output suitable for further text processing with +//! `awk(1)`. +//! +//! This module shares the same API as `log`, except it provides additional +//! entry functions [`init`] and [`init_with_level`] and additional log levels +//! `Level::Fatal` and `Level::Config`. Converting these into the +//! [`log::Level`] is lossy so that `Level::Fatal` becomes `log::Level::Error` +//! and `Level::Config` becomes `log::Level::Debug`. +//! +//! [`log`]: https://docs.rs/log/newest/log/ +//! [Log.jsm]: https://developer.mozilla.org/en/docs/Mozilla/JavaScript_code_modules/Log.jsm +//! [`error!`]: https://docs.rs/log/newest/log/macro.error.html +//! [`warn!`]: https://docs.rs/log/newest/log/macro.warn.html +//! [`info!`]: https://docs.rs/log/newest/log/macro.info.html +//! [`debug!`]: https://docs.rs/log/newest/log/macro.debug.html +//! [`trace!`]: https://docs.rs/log/newest/log/macro.trace.html +//! [`init`]: fn.init.html +//! [`init_with_level`]: fn.init_with_level.html + +use std::fmt; +use std::io; +use std::io::Write; +use std::str; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use chrono; +use log; +use mozprofile::preferences::Pref; + +static MAX_LOG_LEVEL: AtomicUsize = AtomicUsize::new(0); +const LOGGED_TARGETS: &[&str] = &[ + "geckodriver", + "mozdevice", + "mozprofile", + "mozrunner", + "mozversion", + "webdriver", +]; + +/// Logger levels from [Log.jsm]. +/// +/// [Log.jsm]: https://developer.mozilla.org/en/docs/Mozilla/JavaScript_code_modules/Log.jsm +#[repr(usize)] +#[derive(Clone, Copy, Eq, Debug, Hash, PartialEq)] +pub enum Level { + Fatal = 70, + Error = 60, + Warn = 50, + Info = 40, + Config = 30, + Debug = 20, + Trace = 10, +} + +impl From<usize> for Level { + fn from(n: usize) -> Level { + use self::Level::*; + match n { + 70 => Fatal, + 60 => Error, + 50 => Warn, + 40 => Info, + 30 => Config, + 20 => Debug, + 10 => Trace, + _ => Info, + } + } +} + +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use self::Level::*; + let s = match *self { + Fatal => "FATAL", + Error => "ERROR", + Warn => "WARN", + Info => "INFO", + Config => "CONFIG", + Debug => "DEBUG", + Trace => "TRACE", + }; + write!(f, "{}", s) + } +} + +impl str::FromStr for Level { + type Err = (); + + fn from_str(s: &str) -> Result<Level, ()> { + use self::Level::*; + match s.to_lowercase().as_ref() { + "fatal" => Ok(Fatal), + "error" => Ok(Error), + "warn" => Ok(Warn), + "info" => Ok(Info), + "config" => Ok(Config), + "debug" => Ok(Debug), + "trace" => Ok(Trace), + _ => Err(()), + } + } +} + +impl Into<log::Level> for Level { + fn into(self) -> log::Level { + use self::Level::*; + match self { + Fatal | Error => log::Level::Error, + Warn => log::Level::Warn, + Info => log::Level::Info, + Config | Debug => log::Level::Debug, + Trace => log::Level::Trace, + } + } +} + +impl Into<Pref> for Level { + fn into(self) -> Pref { + use self::Level::*; + Pref::new(match self { + Fatal => "Fatal", + Error => "Error", + Warn => "Warn", + Info => "Info", + Config => "Config", + Debug => "Debug", + Trace => "Trace", + }) + } +} + +impl From<log::Level> for Level { + fn from(log_level: log::Level) -> Level { + use log::Level::*; + match log_level { + Error => Level::Error, + Warn => Level::Warn, + Info => Level::Info, + Debug => Level::Debug, + Trace => Level::Trace, + } + } +} + +struct Logger; + +impl log::Log for Logger { + fn enabled(&self, meta: &log::Metadata) -> bool { + LOGGED_TARGETS.iter().any(|&x| meta.target().starts_with(x)) + && meta.level() <= log::max_level() + } + + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + let ts = format_ts(chrono::Local::now()); + println!( + "{}\t{}\t{}\t{}", + ts, + record.target(), + record.level(), + record.args() + ); + } + } + + fn flush(&self) { + io::stdout().flush().unwrap(); + } +} + +/// Initialises the logging subsystem with the default log level. +pub fn init() -> Result<(), log::SetLoggerError> { + init_with_level(Level::Info) +} + +/// Initialises the logging subsystem. +pub fn init_with_level(level: Level) -> Result<(), log::SetLoggerError> { + let logger = Logger {}; + set_max_level(level); + log::set_boxed_logger(Box::new(logger))?; + Ok(()) +} + +/// Returns the current maximum log level. +pub fn max_level() -> Level { + MAX_LOG_LEVEL.load(Ordering::Relaxed).into() +} + +/// Sets the global maximum log level. +pub fn set_max_level(level: Level) { + MAX_LOG_LEVEL.store(level as usize, Ordering::SeqCst); + + let slevel: log::Level = level.into(); + log::set_max_level(slevel.to_level_filter()) +} + +/// Produces a 13-digit Unix Epoch timestamp similar to Gecko. +fn format_ts(ts: chrono::DateTime<chrono::Local>) -> String { + format!("{}{:03}", ts.timestamp(), ts.timestamp_subsec_millis()) +} + +#[cfg(test)] +mod tests { + use super::{format_ts, init_with_level, max_level, set_max_level, Level}; + + use std::str::FromStr; + use std::sync::Mutex; + + use chrono; + use log; + use mozprofile::preferences::{Pref, PrefValue}; + + lazy_static! { + static ref LEVEL_MUTEX: Mutex<()> = Mutex::new(()); + } + + #[test] + fn test_level_repr() { + assert_eq!(Level::Fatal as usize, 70); + assert_eq!(Level::Error as usize, 60); + assert_eq!(Level::Warn as usize, 50); + assert_eq!(Level::Info as usize, 40); + assert_eq!(Level::Config as usize, 30); + assert_eq!(Level::Debug as usize, 20); + assert_eq!(Level::Trace as usize, 10); + } + + #[test] + fn test_level_eq() { + assert_eq!(Level::Fatal, Level::Fatal); + assert_eq!(Level::Error, Level::Error); + assert_eq!(Level::Warn, Level::Warn); + assert_eq!(Level::Info, Level::Info); + assert_eq!(Level::Config, Level::Config); + assert_eq!(Level::Debug, Level::Debug); + assert_eq!(Level::Trace, Level::Trace); + } + + #[test] + fn test_level_from_log() { + assert_eq!(Level::from(log::Level::Error), Level::Error); + assert_eq!(Level::from(log::Level::Warn), Level::Warn); + assert_eq!(Level::from(log::Level::Info), Level::Info); + assert_eq!(Level::from(log::Level::Debug), Level::Debug); + assert_eq!(Level::from(log::Level::Trace), Level::Trace); + } + + #[test] + fn test_level_into_log() { + assert_eq!(Into::<log::Level>::into(Level::Fatal), log::Level::Error); + assert_eq!(Into::<log::Level>::into(Level::Error), log::Level::Error); + assert_eq!(Into::<log::Level>::into(Level::Warn), log::Level::Warn); + assert_eq!(Into::<log::Level>::into(Level::Info), log::Level::Info); + assert_eq!(Into::<log::Level>::into(Level::Config), log::Level::Debug); + assert_eq!(Into::<log::Level>::into(Level::Debug), log::Level::Debug); + assert_eq!(Into::<log::Level>::into(Level::Trace), log::Level::Trace); + } + + #[test] + fn test_level_into_pref() { + let tests = [ + (Level::Fatal, "Fatal"), + (Level::Error, "Error"), + (Level::Warn, "Warn"), + (Level::Info, "Info"), + (Level::Config, "Config"), + (Level::Debug, "Debug"), + (Level::Trace, "Trace"), + ]; + + for &(lvl, s) in tests.iter() { + let expected = Pref { + value: PrefValue::String(s.to_string()), + sticky: false, + }; + assert_eq!(Into::<Pref>::into(lvl), expected); + } + } + + #[test] + fn test_level_from_str() { + assert_eq!(Level::from_str("fatal"), Ok(Level::Fatal)); + assert_eq!(Level::from_str("error"), Ok(Level::Error)); + assert_eq!(Level::from_str("warn"), Ok(Level::Warn)); + assert_eq!(Level::from_str("info"), Ok(Level::Info)); + assert_eq!(Level::from_str("config"), Ok(Level::Config)); + assert_eq!(Level::from_str("debug"), Ok(Level::Debug)); + assert_eq!(Level::from_str("trace"), Ok(Level::Trace)); + + assert_eq!(Level::from_str("INFO"), Ok(Level::Info)); + + assert!(Level::from_str("foo").is_err()); + } + + #[test] + fn test_level_to_str() { + assert_eq!(Level::Fatal.to_string(), "FATAL"); + assert_eq!(Level::Error.to_string(), "ERROR"); + assert_eq!(Level::Warn.to_string(), "WARN"); + assert_eq!(Level::Info.to_string(), "INFO"); + assert_eq!(Level::Config.to_string(), "CONFIG"); + assert_eq!(Level::Debug.to_string(), "DEBUG"); + assert_eq!(Level::Trace.to_string(), "TRACE"); + } + + #[test] + fn test_max_level() { + let _guard = LEVEL_MUTEX.lock(); + set_max_level(Level::Info); + assert_eq!(max_level(), Level::Info); + } + + #[test] + fn test_set_max_level() { + let _guard = LEVEL_MUTEX.lock(); + set_max_level(Level::Error); + assert_eq!(max_level(), Level::Error); + set_max_level(Level::Fatal); + assert_eq!(max_level(), Level::Fatal); + } + + #[test] + fn test_init_with_level() { + let _guard = LEVEL_MUTEX.lock(); + init_with_level(Level::Debug).unwrap(); + assert_eq!(max_level(), Level::Debug); + assert!(init_with_level(Level::Warn).is_err()); + } + + #[test] + fn test_format_ts() { + let ts = chrono::Local::now(); + let s = format_ts(ts); + assert_eq!(s.len(), 13); + } +} diff --git a/testing/geckodriver/src/main.rs b/testing/geckodriver/src/main.rs new file mode 100644 index 0000000000..4dd6c668d9 --- /dev/null +++ b/testing/geckodriver/src/main.rs @@ -0,0 +1,351 @@ +#![forbid(unsafe_code)] + +extern crate chrono; +#[macro_use] +extern crate clap; +#[macro_use] +extern crate lazy_static; +extern crate hyper; +extern crate marionette as marionette_rs; +extern crate mozdevice; +extern crate mozprofile; +extern crate mozrunner; +extern crate mozversion; +extern crate regex; +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate serde_json; +extern crate serde_yaml; +extern crate uuid; +extern crate webdriver; +extern crate zip; + +#[macro_use] +extern crate log; + +use std::env; +use std::fmt; +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::result; +use std::str::FromStr; + +use clap::{App, Arg}; + +macro_rules! try_opt { + ($expr:expr, $err_type:expr, $err_msg:expr) => {{ + match $expr { + Some(x) => x, + None => return Err(WebDriverError::new($err_type, $err_msg)), + } + }}; +} + +mod android; +mod build; +mod capabilities; +mod command; +mod logging; +mod marionette; +mod prefs; + +#[cfg(test)] +pub mod test; + +use crate::command::extension_routes; +use crate::logging::Level; +use crate::marionette::{MarionetteHandler, MarionetteSettings}; +use mozdevice::AndroidStorageInput; + +const EXIT_SUCCESS: i32 = 0; +const EXIT_USAGE: i32 = 64; +const EXIT_UNAVAILABLE: i32 = 69; + +enum FatalError { + Parsing(clap::Error), + Usage(String), + Server(io::Error), +} + +impl FatalError { + fn exit_code(&self) -> i32 { + use FatalError::*; + match *self { + Parsing(_) | Usage(_) => EXIT_USAGE, + Server(_) => EXIT_UNAVAILABLE, + } + } + + fn help_included(&self) -> bool { + match *self { + FatalError::Parsing(_) => true, + _ => false, + } + } +} + +impl From<clap::Error> for FatalError { + fn from(err: clap::Error) -> FatalError { + FatalError::Parsing(err) + } +} + +impl From<io::Error> for FatalError { + fn from(err: io::Error) -> FatalError { + FatalError::Server(err) + } +} + +// harmonise error message from clap to avoid duplicate "error:" prefix +impl fmt::Display for FatalError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use FatalError::*; + let s = match *self { + Parsing(ref err) => err.to_string(), + Usage(ref s) => format!("error: {}", s), + Server(ref err) => format!("error: {}", err.to_string()), + }; + write!(f, "{}", s) + } +} + +macro_rules! usage { + ($msg:expr) => { + return Err(FatalError::Usage($msg.to_string())); + }; + + ($fmt:expr, $($arg:tt)+) => { + return Err(FatalError::Usage(format!($fmt, $($arg)+))); + }; +} + +type ProgramResult<T> = result::Result<T, FatalError>; + +enum Operation { + Help, + Version, + Server { + log_level: Option<Level>, + address: SocketAddr, + settings: MarionetteSettings, + }, +} + +fn parse_args(app: &mut App) -> ProgramResult<Operation> { + let matches = app.get_matches_from_safe_borrow(env::args())?; + + let log_level = if matches.is_present("log_level") { + Level::from_str(matches.value_of("log_level").unwrap()).ok() + } else { + Some(match matches.occurrences_of("verbosity") { + 0 => Level::Info, + 1 => Level::Debug, + _ => Level::Trace, + }) + }; + + let host = matches.value_of("webdriver_host").unwrap(); + let port = { + let s = matches.value_of("webdriver_port").unwrap(); + match u16::from_str(s) { + Ok(n) => n, + Err(e) => usage!("invalid --port: {}: {}", e, s), + } + }; + let address = match IpAddr::from_str(host) { + Ok(addr) => SocketAddr::new(addr, port), + Err(e) => usage!("{}: {}:{}", e, host, port), + }; + + let android_storage = value_t!(matches, "android_storage", AndroidStorageInput)?; + + let binary = matches.value_of("binary").map(PathBuf::from); + + let marionette_host = matches.value_of("marionette_host").unwrap(); + let marionette_port = match matches.value_of("marionette_port") { + Some(s) => match u16::from_str(s) { + Ok(n) => Some(n), + Err(e) => usage!("invalid --marionette-port: {}", e), + }, + None => None, + }; + + let op = if matches.is_present("help") { + Operation::Help + } else if matches.is_present("version") { + Operation::Version + } else { + let settings = MarionetteSettings { + host: marionette_host.to_string(), + port: marionette_port, + binary, + connect_existing: matches.is_present("connect_existing"), + jsdebugger: matches.is_present("jsdebugger"), + android_storage, + }; + Operation::Server { + log_level, + address, + settings, + } + }; + + Ok(op) +} + +fn inner_main(app: &mut App) -> ProgramResult<()> { + match parse_args(app)? { + Operation::Help => print_help(app), + Operation::Version => print_version(), + + Operation::Server { + log_level, + address, + settings, + } => { + if let Some(ref level) = log_level { + logging::init_with_level(*level).unwrap(); + } else { + logging::init().unwrap(); + } + + let handler = MarionetteHandler::new(settings); + let listening = webdriver::server::start(address, handler, extension_routes())?; + info!("Listening on {}", listening.socket); + } + } + + Ok(()) +} + +fn main() { + use std::process::exit; + + let mut app = make_app(); + + // use std::process:Termination when it graduates + exit(match inner_main(&mut app) { + Ok(_) => EXIT_SUCCESS, + + Err(e) => { + eprintln!("{}: {}", get_program_name(), e); + if !e.help_included() { + print_help(&mut app); + } + + e.exit_code() + } + }); +} + +fn make_app<'a, 'b>() -> App<'a, 'b> { + App::new(format!("geckodriver {}", build::build_info())) + .about("WebDriver implementation for Firefox") + .arg( + Arg::with_name("webdriver_host") + .long("host") + .takes_value(true) + .value_name("HOST") + .default_value("127.0.0.1") + .help("Host IP to use for WebDriver server"), + ) + .arg( + Arg::with_name("webdriver_port") + .short("p") + .long("port") + .takes_value(true) + .value_name("PORT") + .default_value("4444") + .help("Port to use for WebDriver server"), + ) + .arg( + Arg::with_name("binary") + .short("b") + .long("binary") + .takes_value(true) + .value_name("BINARY") + .help("Path to the Firefox binary"), + ) + .arg( + Arg::with_name("marionette_host") + .long("marionette-host") + .takes_value(true) + .value_name("HOST") + .default_value("127.0.0.1") + .help("Host to use to connect to Gecko"), + ) + .arg( + Arg::with_name("marionette_port") + .long("marionette-port") + .takes_value(true) + .value_name("PORT") + .help("Port to use to connect to Gecko [default: system-allocated port]"), + ) + .arg( + Arg::with_name("connect_existing") + .long("connect-existing") + .requires("marionette_port") + .help("Connect to an existing Firefox instance"), + ) + .arg( + Arg::with_name("jsdebugger") + .long("jsdebugger") + .help("Attach browser toolbox debugger for Firefox"), + ) + .arg( + Arg::with_name("verbosity") + .multiple(true) + .conflicts_with("log_level") + .short("v") + .help("Log level verbosity (-v for debug and -vv for trace level)"), + ) + .arg( + Arg::with_name("log_level") + .long("log") + .takes_value(true) + .value_name("LEVEL") + .possible_values(&["fatal", "error", "warn", "info", "config", "debug", "trace"]) + .help("Set Gecko log level"), + ) + .arg( + Arg::with_name("help") + .short("h") + .long("help") + .help("Prints this message"), + ) + .arg( + Arg::with_name("version") + .short("V") + .long("version") + .help("Prints version and copying information"), + ) + .arg( + Arg::with_name("android_storage") + .long("android-storage") + .possible_values(&["auto", "app", "internal", "sdcard"]) + .default_value("auto") + .value_name("ANDROID_STORAGE") + .help("Selects storage location to be used for test data."), + ) +} + +fn get_program_name() -> String { + env::args().next().unwrap() +} + +fn print_help(app: &mut App) { + app.print_help().ok(); + println!(); +} + +fn print_version() { + println!("geckodriver {}", build::build_info()); + println!(); + println!("The source code of this program is available from"); + println!("testing/geckodriver in https://hg.mozilla.org/mozilla-central."); + println!(); + println!("This program is subject to the terms of the Mozilla Public License 2.0."); + println!("You can obtain a copy of the license at https://mozilla.org/MPL/2.0/."); +} diff --git a/testing/geckodriver/src/marionette.rs b/testing/geckodriver/src/marionette.rs new file mode 100644 index 0000000000..99a82f3a89 --- /dev/null +++ b/testing/geckodriver/src/marionette.rs @@ -0,0 +1,1749 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::android::AndroidHandler; +use crate::command::{ + AddonInstallParameters, AddonUninstallParameters, GeckoContextParameters, + GeckoExtensionCommand, GeckoExtensionRoute, CHROME_ELEMENT_KEY, +}; +use marionette_rs::common::{ + Cookie as MarionetteCookie, Date as MarionetteDate, Frame as MarionetteFrame, + Timeouts as MarionetteTimeouts, WebElement as MarionetteWebElement, Window, +}; +use marionette_rs::marionette::AppStatus; +use marionette_rs::message::{Command, Message, MessageId, Request}; +use marionette_rs::webdriver::{ + Command as MarionetteWebDriverCommand, Keys as MarionetteKeys, LegacyWebElement, + Locator as MarionetteLocator, NewWindow as MarionetteNewWindow, + PrintMargins as MarionettePrintMargins, PrintOrientation as MarionettePrintOrientation, + PrintPage as MarionettePrintPage, PrintParameters as MarionettePrintParameters, + ScreenshotOptions, Script as MarionetteScript, Selector as MarionetteSelector, + Url as MarionetteUrl, WindowRect as MarionetteWindowRect, +}; +use mozdevice::AndroidStorageInput; +use mozprofile::preferences::Pref; +use mozprofile::profile::Profile; +use mozrunner::runner::{FirefoxProcess, FirefoxRunner, Runner, RunnerProcess}; +use serde::de::{self, Deserialize, Deserializer}; +use serde::ser::{Serialize, Serializer}; +use serde_json::{self, Map, Value}; +use std::io::prelude::*; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::thread; +use std::time; +use webdriver::capabilities::CapabilitiesMatching; +use webdriver::command::WebDriverCommand::{ + AcceptAlert, AddCookie, CloseWindow, DeleteCookie, DeleteCookies, DeleteSession, DismissAlert, + ElementClear, ElementClick, ElementSendKeys, ExecuteAsyncScript, ExecuteScript, Extension, + FindElement, FindElementElement, FindElementElements, FindElements, FullscreenWindow, Get, + GetActiveElement, GetAlertText, GetCSSValue, GetCookies, GetCurrentUrl, GetElementAttribute, + GetElementProperty, GetElementRect, GetElementTagName, GetElementText, GetNamedCookie, + GetPageSource, GetTimeouts, GetTitle, GetWindowHandle, GetWindowHandles, GetWindowRect, GoBack, + GoForward, IsDisplayed, IsEnabled, IsSelected, MaximizeWindow, MinimizeWindow, NewSession, + NewWindow, PerformActions, Print, Refresh, ReleaseActions, SendAlertText, SetTimeouts, + SetWindowRect, Status, SwitchToFrame, SwitchToParentFrame, SwitchToWindow, + TakeElementScreenshot, TakeScreenshot, +}; +use webdriver::command::{ + ActionsParameters, AddCookieParameters, GetNamedCookieParameters, GetParameters, + JavascriptCommandParameters, LocatorParameters, NewSessionParameters, NewWindowParameters, + PrintMargins, PrintOrientation, PrintPage, PrintParameters, SendKeysParameters, + SwitchToFrameParameters, SwitchToWindowParameters, TimeoutsParameters, WindowRectParameters, +}; +use webdriver::command::{WebDriverCommand, WebDriverMessage}; +use webdriver::common::{ + Cookie, Date, FrameId, LocatorStrategy, WebElement, ELEMENT_KEY, FRAME_KEY, WINDOW_KEY, +}; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; +use webdriver::response::{ + CloseWindowResponse, CookieResponse, CookiesResponse, ElementRectResponse, NewSessionResponse, + NewWindowResponse, TimeoutsResponse, ValueResponse, WebDriverResponse, WindowRectResponse, +}; +use webdriver::server::{Session, WebDriverHandler}; + +use crate::build; +use crate::capabilities::{FirefoxCapabilities, FirefoxOptions}; +use crate::logging; +use crate::prefs; + +/// A running Gecko instance. +#[derive(Debug)] +pub enum Browser { + /// A local Firefox process, running on this (host) device. + Host(FirefoxProcess), + + /// A remote instance, running on a (target) Android device. + Target(AndroidHandler), +} + +#[derive(Debug, PartialEq, Deserialize)] +pub struct MarionetteHandshake { + #[serde(rename = "marionetteProtocol")] + protocol: u16, + #[serde(rename = "applicationType")] + application_type: String, +} + +#[derive(Default)] +pub struct MarionetteSettings { + pub host: String, + pub port: Option<u16>, + pub binary: Option<PathBuf>, + pub connect_existing: bool, + + /// Brings up the Browser Toolbox when starting Firefox, + /// letting you debug internals. + pub jsdebugger: bool, + + pub android_storage: AndroidStorageInput, +} + +#[derive(Default)] +pub struct MarionetteHandler { + pub connection: Mutex<Option<MarionetteConnection>>, + pub settings: MarionetteSettings, + pub browser: Option<Browser>, +} + +impl MarionetteHandler { + pub fn new(settings: MarionetteSettings) -> MarionetteHandler { + MarionetteHandler { + connection: Mutex::new(None), + settings, + browser: None, + } + } + + pub fn create_connection( + &mut self, + session_id: &Option<String>, + new_session_parameters: &NewSessionParameters, + ) -> WebDriverResult<Map<String, Value>> { + let (options, capabilities) = { + let mut fx_capabilities = FirefoxCapabilities::new(self.settings.binary.as_ref()); + let mut capabilities = new_session_parameters + .match_browser(&mut fx_capabilities)? + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Unable to find a matching set of capabilities", + ) + })?; + + let options = FirefoxOptions::from_capabilities( + fx_capabilities.chosen_binary, + self.settings.android_storage, + &mut capabilities, + )?; + (options, capabilities) + }; + + if let Some(l) = options.log.level { + logging::set_max_level(l); + } + + let host = self.settings.host.to_owned(); + let port = self.settings.port.unwrap_or(get_free_port(&host)?); + + match options.android { + Some(_) => { + // TODO: support connecting to running Apps. There's no real obstruction here, + // just some details about port forwarding to work through. We can't follow + // `chromedriver` here since it uses an abstract socket rather than a TCP socket: + // see bug 1240830 for thoughts on doing that for Marionette. + if self.settings.connect_existing { + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Cannot connect to an existing Android App yet", + )); + } + + self.start_android(port, options)?; + } + None => { + if !self.settings.connect_existing { + self.start_browser(port, options)?; + } + } + } + + let mut connection = MarionetteConnection::new(host, port, session_id.clone()); + connection.connect(&mut self.browser).or_else(|e| { + match self.browser { + Some(Browser::Host(ref mut runner)) => { + runner.kill()?; + } + Some(Browser::Target(ref mut handler)) => { + handler.force_stop().map_err(|e| { + WebDriverError::new(ErrorStatus::UnknownError, e.to_string()) + })?; + } + _ => {} + } + + Err(e) + })?; + self.connection = Mutex::new(Some(connection)); + Ok(capabilities) + } + + fn start_android(&mut self, port: u16, options: FirefoxOptions) -> WebDriverResult<()> { + let android_options = options.android.unwrap(); + + let handler = AndroidHandler::new(&android_options, port)?; + + // Profile management. + let is_custom_profile = options.profile.is_some(); + + let mut profile = options.profile.unwrap_or(Profile::new()?); + + self.set_prefs( + handler.target_port, + &mut profile, + is_custom_profile, + options.prefs, + ) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + + handler + .prepare(&profile, options.env.unwrap_or_default()) + .map_err(|e| WebDriverError::new(ErrorStatus::UnknownError, e.to_string()))?; + + handler + .launch() + .map_err(|e| WebDriverError::new(ErrorStatus::UnknownError, e.to_string()))?; + + self.browser = Some(Browser::Target(handler)); + + Ok(()) + } + + fn start_browser(&mut self, port: u16, options: FirefoxOptions) -> WebDriverResult<()> { + let binary = options.binary.ok_or_else(|| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Expected browser binary location, but unable to find \ + binary in default location, no \ + 'moz:firefoxOptions.binary' capability provided, and \ + no binary flag set on the command line", + ) + })?; + + let is_custom_profile = options.profile.is_some(); + + let mut profile = match options.profile { + Some(x) => x, + None => Profile::new()?, + }; + + self.set_prefs(port, &mut profile, is_custom_profile, options.prefs) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + + let mut runner = FirefoxRunner::new(&binary, profile); + + runner.arg("--marionette"); + if self.settings.jsdebugger { + runner.arg("--jsdebugger"); + } + if let Some(args) = options.args.as_ref() { + runner.args(args); + } + + // https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting + runner + .env("MOZ_CRASHREPORTER", "1") + .env("MOZ_CRASHREPORTER_NO_REPORT", "1") + .env("MOZ_CRASHREPORTER_SHUTDOWN", "1"); + + let browser_proc = runner.start().map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to start browser {}: {}", binary.display(), e), + ) + })?; + self.browser = Some(Browser::Host(browser_proc)); + + Ok(()) + } + + pub fn set_prefs( + &self, + port: u16, + profile: &mut Profile, + custom_profile: bool, + extra_prefs: Vec<(String, Pref)>, + ) -> WebDriverResult<()> { + let prefs = profile.user_prefs().map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Unable to read profile preferences file", + ) + })?; + + for &(ref name, ref value) in prefs::DEFAULT.iter() { + if !custom_profile || !prefs.contains_key(name) { + prefs.insert((*name).to_string(), (*value).clone()); + } + } + + prefs.insert_slice(&extra_prefs[..]); + + if self.settings.jsdebugger { + prefs.insert("devtools.browsertoolbox.panel", Pref::new("jsdebugger")); + prefs.insert("devtools.debugger.remote-enabled", Pref::new(true)); + prefs.insert("devtools.chrome.enabled", Pref::new(true)); + prefs.insert("devtools.debugger.prompt-connection", Pref::new(false)); + prefs.insert("marionette.debugging.clicktostart", Pref::new(true)); + } + + prefs.insert("marionette.log.level", logging::max_level().into()); + prefs.insert("marionette.port", Pref::new(port)); + + prefs.write().map_err(|e| { + WebDriverError::new( + ErrorStatus::UnknownError, + format!("Unable to write Firefox profile: {}", e), + ) + }) + } +} + +impl WebDriverHandler<GeckoExtensionRoute> for MarionetteHandler { + fn handle_command( + &mut self, + _: &Option<Session>, + msg: WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<WebDriverResponse> { + let mut resolved_capabilities = None; + { + let mut capabilities_options = None; + // First handle the status message which doesn't actually require a marionette + // connection or message + if let Status = msg.command { + let (ready, message) = self + .connection + .lock() + .map(|ref connection| { + connection + .as_ref() + .map(|_| (false, "Session already started")) + .unwrap_or((true, "")) + }) + .unwrap_or((false, "geckodriver internal error")); + let mut value = Map::new(); + value.insert("ready".to_string(), Value::Bool(ready)); + value.insert("message".to_string(), Value::String(message.into())); + return Ok(WebDriverResponse::Generic(ValueResponse(Value::Object( + value, + )))); + } + + match self.connection.lock() { + Ok(ref connection) => { + if connection.is_none() { + match msg.command { + NewSession(ref capabilities) => { + capabilities_options = Some(capabilities); + } + _ => { + return Err(WebDriverError::new( + ErrorStatus::InvalidSessionId, + "Tried to run command without establishing a connection", + )); + } + } + } + } + Err(_) => { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to aquire Marionette connection", + )) + } + } + if let Some(capabilities) = capabilities_options { + resolved_capabilities = + Some(self.create_connection(&msg.session_id, &capabilities)?); + } + } + + match self.connection.lock() { + Ok(ref mut connection) => { + match connection.as_mut() { + Some(conn) => { + conn.send_command(resolved_capabilities, &msg) + .map_err(|mut err| { + // Shutdown the browser if no session can + // be established due to errors. + if let NewSession(_) = msg.command { + err.delete_session = true; + } + err + }) + } + None => panic!("Connection missing"), + } + } + Err(_) => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to aquire Marionette connection", + )), + } + } + + fn delete_session(&mut self, session: &Option<Session>) { + if let Some(ref s) = *session { + let delete_session = WebDriverMessage { + session_id: Some(s.id.clone()), + command: WebDriverCommand::DeleteSession, + }; + let _ = self.handle_command(session, delete_session); + } + + if let Ok(ref mut connection) = self.connection.lock() { + if let Some(conn) = connection.as_mut() { + conn.close(); + } + } + + match self.browser { + Some(Browser::Host(ref mut runner)) => { + // TODO(https://bugzil.la/1443922): + // Use toolkit.asyncshutdown.crash_timout pref + match runner.wait(time::Duration::from_secs(70)) { + Ok(x) => debug!("Browser process stopped: {}", x), + Err(e) => error!("Failed to stop browser process: {}", e), + } + } + Some(Browser::Target(ref mut handler)) => { + // Try to force-stop the process on the target device + match handler.force_stop() { + Ok(_) => debug!("Android package force-stopped"), + Err(e) => error!("Failed to force-stop Android package: {}", e), + } + } + None => {} + } + + self.connection = Mutex::new(None); + self.browser = None; + } +} + +pub struct MarionetteSession { + pub session_id: String, + protocol: Option<u16>, + application_type: Option<String>, + command_id: MessageId, +} + +impl MarionetteSession { + pub fn new(session_id: Option<String>) -> MarionetteSession { + let initital_id = session_id.unwrap_or_else(|| "".to_string()); + MarionetteSession { + session_id: initital_id, + protocol: None, + application_type: None, + command_id: 0, + } + } + + pub fn update( + &mut self, + msg: &WebDriverMessage<GeckoExtensionRoute>, + resp: &MarionetteResponse, + ) -> WebDriverResult<()> { + if let NewSession(_) = msg.command { + let session_id = try_opt!( + try_opt!( + resp.result.get("sessionId"), + ErrorStatus::SessionNotCreated, + "Unable to get session id" + ) + .as_str(), + ErrorStatus::SessionNotCreated, + "Unable to convert session id to string" + ); + self.session_id = session_id.to_string().clone(); + }; + Ok(()) + } + + /// Converts a Marionette JSON response into a `WebElement`. + /// + /// Note that it currently coerces all chrome elements, web frames, and web + /// windows also into web elements. This will change at a later point. + fn to_web_element(&self, json_data: &Value) -> WebDriverResult<WebElement> { + let data = try_opt!( + json_data.as_object(), + ErrorStatus::UnknownError, + "Failed to convert data to an object" + ); + + let chrome_element = data.get(CHROME_ELEMENT_KEY); + let element = data.get(ELEMENT_KEY); + let frame = data.get(FRAME_KEY); + let window = data.get(WINDOW_KEY); + + let value = try_opt!( + element.or(chrome_element).or(frame).or(window), + ErrorStatus::UnknownError, + "Failed to extract web element from Marionette response" + ); + let id = try_opt!( + value.as_str(), + ErrorStatus::UnknownError, + "Failed to convert web element reference value to string" + ) + .to_string(); + Ok(WebElement(id)) + } + + pub fn next_command_id(&mut self) -> MessageId { + self.command_id += 1; + self.command_id + } + + pub fn response( + &mut self, + msg: &WebDriverMessage<GeckoExtensionRoute>, + resp: MarionetteResponse, + ) -> WebDriverResult<WebDriverResponse> { + use self::GeckoExtensionCommand::*; + + if resp.id != self.command_id { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!( + "Marionette responses arrived out of sequence, expected {}, got {}", + self.command_id, resp.id + ), + )); + } + + if let Some(error) = resp.error { + return Err(error.into()); + } + + self.update(msg, &resp)?; + + Ok(match msg.command { + // Everything that doesn't have a response value + Get(_) + | GoBack + | GoForward + | Refresh + | SetTimeouts(_) + | SwitchToWindow(_) + | SwitchToFrame(_) + | SwitchToParentFrame + | AddCookie(_) + | DeleteCookies + | DeleteCookie(_) + | DismissAlert + | AcceptAlert + | SendAlertText(_) + | ElementClick(_) + | ElementClear(_) + | ElementSendKeys(_, _) + | PerformActions(_) + | ReleaseActions => WebDriverResponse::Void, + // Things that simply return the contents of the marionette "value" property + GetCurrentUrl + | GetTitle + | GetPageSource + | GetWindowHandle + | IsDisplayed(_) + | IsSelected(_) + | GetElementAttribute(_, _) + | GetElementProperty(_, _) + | GetCSSValue(_, _) + | GetElementText(_) + | GetElementTagName(_) + | IsEnabled(_) + | ExecuteScript(_) + | ExecuteAsyncScript(_) + | GetAlertText + | TakeScreenshot + | Print(_) + | TakeElementScreenshot(_) => { + WebDriverResponse::Generic(resp.into_value_response(true)?) + } + GetTimeouts => { + let script = match try_opt!( + resp.result.get("script"), + ErrorStatus::UnknownError, + "Missing field: script" + ) { + Value::Null => None, + n => try_opt!( + Some(n.as_u64()), + ErrorStatus::UnknownError, + "Failed to interpret script timeout duration as u64" + ), + }; + // Check for the spec-compliant "pageLoad", but also for "page load", + // which was sent by Firefox 52 and earlier. + let page_load = try_opt!( + try_opt!( + resp.result + .get("pageLoad") + .or_else(|| resp.result.get("page load")), + ErrorStatus::UnknownError, + "Missing field: pageLoad" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret page load duration as u64" + ); + let implicit = try_opt!( + try_opt!( + resp.result.get("implicit"), + ErrorStatus::UnknownError, + "Missing field: implicit" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret implicit search duration as u64" + ); + + WebDriverResponse::Timeouts(TimeoutsResponse { + script, + page_load, + implicit, + }) + } + Status => panic!("Got status command that should already have been handled"), + GetWindowHandles => WebDriverResponse::Generic(resp.into_value_response(false)?), + NewWindow(_) => { + let handle: String = try_opt!( + try_opt!( + resp.result.get("handle"), + ErrorStatus::UnknownError, + "Failed to find handle field" + ) + .as_str(), + ErrorStatus::UnknownError, + "Failed to interpret handle as string" + ) + .into(); + let typ: String = try_opt!( + try_opt!( + resp.result.get("type"), + ErrorStatus::UnknownError, + "Failed to find type field" + ) + .as_str(), + ErrorStatus::UnknownError, + "Failed to interpret type as string" + ) + .into(); + + WebDriverResponse::NewWindow(NewWindowResponse { handle, typ }) + } + CloseWindow => { + let data = try_opt!( + resp.result.as_array(), + ErrorStatus::UnknownError, + "Failed to interpret value as array" + ); + let handles = data + .iter() + .map(|x| { + Ok(try_opt!( + x.as_str(), + ErrorStatus::UnknownError, + "Failed to interpret window handle as string" + ) + .to_owned()) + }) + .collect::<Result<Vec<_>, _>>()?; + WebDriverResponse::CloseWindow(CloseWindowResponse(handles)) + } + GetElementRect(_) => { + let x = try_opt!( + try_opt!( + resp.result.get("x"), + ErrorStatus::UnknownError, + "Failed to find x field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret x as float" + ); + + let y = try_opt!( + try_opt!( + resp.result.get("y"), + ErrorStatus::UnknownError, + "Failed to find y field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret y as float" + ); + + let width = try_opt!( + try_opt!( + resp.result.get("width"), + ErrorStatus::UnknownError, + "Failed to find width field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret width as float" + ); + + let height = try_opt!( + try_opt!( + resp.result.get("height"), + ErrorStatus::UnknownError, + "Failed to find height field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret width as float" + ); + + let rect = ElementRectResponse { + x, + y, + width, + height, + }; + WebDriverResponse::ElementRect(rect) + } + FullscreenWindow | MinimizeWindow | MaximizeWindow | GetWindowRect + | SetWindowRect(_) => { + let width = try_opt!( + try_opt!( + resp.result.get("width"), + ErrorStatus::UnknownError, + "Failed to find width field" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret width as positive integer" + ); + + let height = try_opt!( + try_opt!( + resp.result.get("height"), + ErrorStatus::UnknownError, + "Failed to find heigenht field" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret height as positive integer" + ); + + let x = try_opt!( + try_opt!( + resp.result.get("x"), + ErrorStatus::UnknownError, + "Failed to find x field" + ) + .as_i64(), + ErrorStatus::UnknownError, + "Failed to interpret x as integer" + ); + + let y = try_opt!( + try_opt!( + resp.result.get("y"), + ErrorStatus::UnknownError, + "Failed to find y field" + ) + .as_i64(), + ErrorStatus::UnknownError, + "Failed to interpret y as integer" + ); + + let rect = WindowRectResponse { + x: x as i32, + y: y as i32, + width: width as i32, + height: height as i32, + }; + WebDriverResponse::WindowRect(rect) + } + GetCookies => { + let cookies: Vec<Cookie> = serde_json::from_value(resp.result)?; + WebDriverResponse::Cookies(CookiesResponse(cookies)) + } + GetNamedCookie(ref name) => { + let mut cookies: Vec<Cookie> = serde_json::from_value(resp.result)?; + cookies.retain(|x| x.name == *name); + let cookie = try_opt!( + cookies.pop(), + ErrorStatus::NoSuchCookie, + format!("No cookie with name {}", name) + ); + WebDriverResponse::Cookie(CookieResponse(cookie)) + } + FindElement(_) | FindElementElement(_, _) => { + let element = self.to_web_element(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(element)?)) + } + FindElements(_) | FindElementElements(_, _) => { + let element_vec = try_opt!( + resp.result.as_array(), + ErrorStatus::UnknownError, + "Failed to interpret value as array" + ); + let elements = element_vec + .iter() + .map(|x| self.to_web_element(x)) + .collect::<Result<Vec<_>, _>>()?; + + // TODO(Henrik): How to remove unwrap? + WebDriverResponse::Generic(ValueResponse(Value::Array( + elements + .iter() + .map(|x| serde_json::to_value(x).unwrap()) + .collect(), + ))) + } + GetActiveElement => { + let element = self.to_web_element(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(element)?)) + } + NewSession(_) => { + let session_id = try_opt!( + try_opt!( + resp.result.get("sessionId"), + ErrorStatus::InvalidSessionId, + "Failed to find sessionId field" + ) + .as_str(), + ErrorStatus::InvalidSessionId, + "sessionId is not a string" + ); + + let mut capabilities = try_opt!( + try_opt!( + resp.result.get("capabilities"), + ErrorStatus::UnknownError, + "Failed to find capabilities field" + ) + .as_object(), + ErrorStatus::UnknownError, + "capabilities field is not an object" + ) + .clone(); + + capabilities.insert("moz:geckodriverVersion".into(), build::build_info().into()); + + WebDriverResponse::NewSession(NewSessionResponse::new( + session_id.to_string(), + Value::Object(capabilities.clone()), + )) + } + DeleteSession => WebDriverResponse::DeleteSession, + Extension(ref extension) => match extension { + GetContext => WebDriverResponse::Generic(resp.into_value_response(true)?), + SetContext(_) => WebDriverResponse::Void, + InstallAddon(_) => WebDriverResponse::Generic(resp.into_value_response(true)?), + UninstallAddon(_) => WebDriverResponse::Void, + TakeFullScreenshot => WebDriverResponse::Generic(resp.into_value_response(true)?), + }, + }) + } +} + +fn try_convert_to_marionette_message( + msg: &WebDriverMessage<GeckoExtensionRoute>, +) -> WebDriverResult<Option<Command>> { + use self::GeckoExtensionCommand::*; + use self::WebDriverCommand::*; + + Ok(match msg.command { + AcceptAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::AcceptAlert)), + AddCookie(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::AddCookie( + x.to_marionette()?, + ))), + CloseWindow => Some(Command::WebDriver(MarionetteWebDriverCommand::CloseWindow)), + DeleteCookie(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteCookie(x.clone()), + )), + DeleteCookies => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteCookies, + )), + DeleteSession => Some(Command::Marionette( + marionette_rs::marionette::Command::DeleteSession { + flags: vec![AppStatus::eForceQuit], + }, + )), + DismissAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::DismissAlert)), + ElementClear(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClear(e.to_marionette()?), + )), + ElementClick(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClick(e.to_marionette()?), + )), + ElementSendKeys(ref e, ref x) => { + let keys = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementSendKeys { + id: e.clone().to_string(), + text: keys.text.clone(), + value: keys.value.clone(), + }, + )) + } + ExecuteAsyncScript(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ExecuteAsyncScript(x.to_marionette()?), + )), + ExecuteScript(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ExecuteScript(x.to_marionette()?), + )), + FindElement(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::FindElement( + x.to_marionette()?, + ))), + FindElements(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElements(x.to_marionette()?), + )), + FindElementElement(ref e, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElementElement { + element: e.clone().to_string(), + using: locator.using.clone(), + value: locator.value.clone(), + }, + )) + } + FindElementElements(ref e, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElementElements { + element: e.clone().to_string(), + using: locator.using.clone(), + value: locator.value.clone(), + }, + )) + } + FullscreenWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::FullscreenWindow, + )), + Get(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::Get( + x.to_marionette()?, + ))), + GetActiveElement => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetActiveElement, + )), + GetAlertText => Some(Command::WebDriver(MarionetteWebDriverCommand::GetAlertText)), + GetCookies | GetNamedCookie(_) => { + Some(Command::WebDriver(MarionetteWebDriverCommand::GetCookies)) + } + GetCSSValue(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetCSSValue { + id: e.clone().to_string(), + property: x.clone(), + }, + )), + GetCurrentUrl => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetCurrentUrl, + )), + GetElementAttribute(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementAttribute { + id: e.clone().to_string(), + name: x.clone(), + }, + )), + GetElementProperty(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementProperty { + id: e.clone().to_string(), + name: x.clone(), + }, + )), + GetElementRect(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementRect(x.to_marionette()?), + )), + GetElementTagName(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementTagName(x.to_marionette()?), + )), + GetElementText(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementText(x.to_marionette()?), + )), + GetPageSource => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetPageSource, + )), + GetTitle => Some(Command::WebDriver(MarionetteWebDriverCommand::GetTitle)), + GetWindowHandle => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowHandle, + )), + GetWindowHandles => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowHandles, + )), + GetWindowRect => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowRect, + )), + GetTimeouts => Some(Command::WebDriver(MarionetteWebDriverCommand::GetTimeouts)), + GoBack => Some(Command::WebDriver(MarionetteWebDriverCommand::GoBack)), + GoForward => Some(Command::WebDriver(MarionetteWebDriverCommand::GoForward)), + IsDisplayed(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsDisplayed( + x.to_marionette()?, + ))), + IsEnabled(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsEnabled( + x.to_marionette()?, + ))), + IsSelected(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsSelected( + x.to_marionette()?, + ))), + MaximizeWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::MaximizeWindow, + )), + MinimizeWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::MinimizeWindow, + )), + NewWindow(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::NewWindow( + x.to_marionette()?, + ))), + Print(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::Print( + x.to_marionette()?, + ))), + Refresh => Some(Command::WebDriver(MarionetteWebDriverCommand::Refresh)), + ReleaseActions => Some(Command::WebDriver( + MarionetteWebDriverCommand::ReleaseActions, + )), + SendAlertText(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SendAlertText(x.to_marionette()?), + )), + SetTimeouts(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::SetTimeouts( + x.to_marionette()?, + ))), + SetWindowRect(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SetWindowRect(x.to_marionette()?), + )), + SwitchToFrame(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToFrame(x.to_marionette()?), + )), + SwitchToParentFrame => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToParentFrame, + )), + SwitchToWindow(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToWindow(x.to_marionette()?), + )), + TakeElementScreenshot(ref e) => { + let screenshot = ScreenshotOptions { + id: Some(e.clone().to_string()), + highlights: vec![], + full: false, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeElementScreenshot(screenshot), + )) + } + TakeScreenshot => { + let screenshot = ScreenshotOptions { + id: None, + highlights: vec![], + full: false, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeScreenshot(screenshot), + )) + } + Extension(ref extension) => match extension { + TakeFullScreenshot => { + let screenshot = ScreenshotOptions { + id: None, + highlights: vec![], + full: true, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeFullScreenshot(screenshot), + )) + } + _ => None, + }, + _ => None, + }) +} + +#[derive(Debug, PartialEq)] +pub struct MarionetteCommand { + pub id: MessageId, + pub name: String, + pub params: Map<String, Value>, +} + +impl Serialize for MarionetteCommand { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let data = (&0, &self.id, &self.name, &self.params); + data.serialize(serializer) + } +} + +impl MarionetteCommand { + fn new(id: MessageId, name: String, params: Map<String, Value>) -> MarionetteCommand { + MarionetteCommand { id, name, params } + } + + fn encode_msg<T>(msg: T) -> WebDriverResult<String> + where + T: serde::Serialize, + { + let data = serde_json::to_string(&msg)?; + + Ok(format!("{}:{}", data.len(), data)) + } + + fn from_webdriver_message( + id: MessageId, + capabilities: Option<Map<String, Value>>, + msg: &WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<String> { + use self::GeckoExtensionCommand::*; + + if let Some(cmd) = try_convert_to_marionette_message(msg)? { + let req = Message::Incoming(Request(id, cmd)); + MarionetteCommand::encode_msg(req) + } else { + let (opt_name, opt_parameters) = match msg.command { + Status => panic!("Got status command that should already have been handled"), + NewSession(_) => { + let caps = capabilities + .expect("Tried to create new session without processing capabilities"); + + let mut data = Map::new(); + for (k, v) in caps.iter() { + data.insert(k.to_string(), serde_json::to_value(v)?); + } + + (Some("WebDriver:NewSession"), Some(Ok(data))) + } + PerformActions(ref x) => { + (Some("WebDriver:PerformActions"), Some(x.to_marionette())) + } + Extension(ref extension) => match extension { + GetContext => (Some("Marionette:GetContext"), None), + InstallAddon(x) => (Some("Addon:Install"), Some(x.to_marionette())), + SetContext(x) => (Some("Marionette:SetContext"), Some(x.to_marionette())), + UninstallAddon(x) => (Some("Addon:Uninstall"), Some(x.to_marionette())), + _ => (None, None), + }, + _ => (None, None), + }; + + let name = try_opt!( + opt_name, + ErrorStatus::UnsupportedOperation, + "Operation not supported" + ); + let parameters = opt_parameters.unwrap_or_else(|| Ok(Map::new()))?; + + let req = MarionetteCommand::new(id, name.into(), parameters); + MarionetteCommand::encode_msg(req) + } + } +} + +#[derive(Debug, PartialEq)] +pub struct MarionetteResponse { + pub id: MessageId, + pub error: Option<MarionetteError>, + pub result: Value, +} + +impl<'de> Deserialize<'de> for MarionetteResponse { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct ResponseWrapper { + msg_type: u64, + id: MessageId, + error: Option<MarionetteError>, + result: Value, + } + + let wrapper: ResponseWrapper = Deserialize::deserialize(deserializer)?; + + if wrapper.msg_type != 1 { + return Err(de::Error::custom( + "Expected '1' in first element of response", + )); + }; + + Ok(MarionetteResponse { + id: wrapper.id, + error: wrapper.error, + result: wrapper.result, + }) + } +} + +impl MarionetteResponse { + fn into_value_response(self, value_required: bool) -> WebDriverResult<ValueResponse> { + let value: &Value = if value_required { + try_opt!( + self.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ) + } else { + &self.result + }; + + Ok(ValueResponse(value.clone())) + } +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct MarionetteError { + #[serde(rename = "error")] + pub code: String, + pub message: String, + pub stacktrace: Option<String>, +} + +impl Into<WebDriverError> for MarionetteError { + fn into(self) -> WebDriverError { + let status = ErrorStatus::from(self.code); + let message = self.message; + + if let Some(stack) = self.stacktrace { + WebDriverError::new_with_stack(status, message, stack) + } else { + WebDriverError::new(status, message) + } + } +} + +fn get_free_port(host: &str) -> IoResult<u16> { + TcpListener::bind((host, 0)) + .and_then(|stream| stream.local_addr()) + .map(|x| x.port()) +} + +pub struct MarionetteConnection { + host: String, + port: u16, + stream: Option<TcpStream>, + pub session: MarionetteSession, +} + +impl MarionetteConnection { + pub fn new(host: String, port: u16, session_id: Option<String>) -> MarionetteConnection { + let session = MarionetteSession::new(session_id); + MarionetteConnection { + host, + port, + stream: None, + session, + } + } + + pub fn connect(&mut self, browser: &mut Option<Browser>) -> WebDriverResult<()> { + let timeout = time::Duration::from_secs(60); + let poll_interval = time::Duration::from_millis(100); + let now = time::Instant::now(); + + debug!( + "Waiting {}s to connect to browser on {}:{}", + timeout.as_secs(), + self.host, + self.port + ); + + loop { + // immediately abort connection attempts if process disappears + if let Some(Browser::Host(ref mut runner)) = *browser { + let exit_status = match runner.try_wait() { + Ok(Some(status)) => Some( + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()), + ), + Ok(None) => None, + Err(_) => Some("{unknown}".into()), + }; + if let Some(s) = exit_status { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!("Process unexpectedly closed with status {}", s), + )); + } + } + + let try_connect = || -> WebDriverResult<(TcpStream, MarionetteHandshake)> { + let mut stream = TcpStream::connect((&self.host[..], self.port))?; + let data = MarionetteConnection::handshake(&mut stream)?; + + Ok((stream, data)) + }; + + match try_connect() { + Ok((stream, data)) => { + debug!( + "Connection to Marionette established on {}:{}.", + self.host, self.port, + ); + + self.stream = Some(stream); + self.session.application_type = Some(data.application_type); + self.session.protocol = Some(data.protocol); + break; + } + Err(e) => { + if now.elapsed() < timeout { + thread::sleep(poll_interval); + } else { + return Err(WebDriverError::new(ErrorStatus::Timeout, e.to_string())); + } + } + } + } + + Ok(()) + } + + fn handshake(stream: &mut TcpStream) -> WebDriverResult<MarionetteHandshake> { + let resp = (match stream.read_timeout() { + Ok(timeout) => { + // If platform supports changing the read timeout of the stream, + // use a short one only for the handshake with Marionette. + stream + .set_read_timeout(Some(time::Duration::from_millis(100))) + .ok(); + let data = MarionetteConnection::read_resp(stream); + stream.set_read_timeout(timeout).ok(); + + data + } + _ => MarionetteConnection::read_resp(stream), + }) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::UnknownError, + format!("Socket timeout reading Marionette handshake data: {}", e), + ) + })?; + + let data = serde_json::from_str::<MarionetteHandshake>(&resp)?; + + if data.application_type != "gecko" { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!("Unrecognized application type {}", data.application_type), + )); + } + + if data.protocol != 3 { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!( + "Unsupported Marionette protocol version {}, required 3", + data.protocol + ), + )); + } + + Ok(data) + } + + pub fn close(&self) {} + + pub fn send_command( + &mut self, + capabilities: Option<Map<String, Value>>, + msg: &WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<WebDriverResponse> { + let id = self.session.next_command_id(); + let enc_cmd = MarionetteCommand::from_webdriver_message(id, capabilities, msg)?; + let resp_data = self.send(enc_cmd)?; + let data: MarionetteResponse = serde_json::from_str(&resp_data)?; + + self.session.response(msg, data) + } + + fn send(&mut self, data: String) -> WebDriverResult<String> { + let stream = match self.stream { + Some(ref mut stream) => { + if stream.write(&*data.as_bytes()).is_err() { + let mut err = WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to write request to stream", + ); + err.delete_session = true; + return Err(err); + } + + stream + } + None => { + let mut err = WebDriverError::new( + ErrorStatus::UnknownError, + "Tried to write before opening stream", + ); + err.delete_session = true; + return Err(err); + } + }; + + match MarionetteConnection::read_resp(stream) { + Ok(resp) => Ok(resp), + Err(_) => { + let mut err = WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to decode response from marionette", + ); + err.delete_session = true; + Err(err) + } + } + } + + fn read_resp(stream: &mut TcpStream) -> IoResult<String> { + let mut bytes = 0usize; + + loop { + let buf = &mut [0 as u8]; + let num_read = stream.read(buf)?; + let byte = match num_read { + 0 => { + return Err(IoError::new( + ErrorKind::Other, + "EOF reading marionette message", + )) + } + 1 => buf[0] as char, + _ => panic!("Expected one byte got more"), + }; + match byte { + '0'..='9' => { + bytes *= 10; + bytes += byte as usize - '0' as usize; + } + ':' => break, + _ => {} + } + } + + let buf = &mut [0 as u8; 8192]; + let mut payload = Vec::with_capacity(bytes); + let mut total_read = 0; + while total_read < bytes { + let num_read = stream.read(buf)?; + if num_read == 0 { + return Err(IoError::new( + ErrorKind::Other, + "EOF reading marionette message", + )); + } + total_read += num_read; + for x in &buf[..num_read] { + payload.push(*x); + } + } + + // TODO(jgraham): Need to handle the error here + Ok(String::from_utf8(payload).unwrap()) + } +} + +trait ToMarionette<T> { + fn to_marionette(&self) -> WebDriverResult<T>; +} + +impl ToMarionette<Map<String, Value>> for AddonInstallParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert("path".to_string(), serde_json::to_value(&self.path)?); + if self.temporary.is_some() { + data.insert( + "temporary".to_string(), + serde_json::to_value(&self.temporary)?, + ); + } + Ok(data) + } +} + +impl ToMarionette<Map<String, Value>> for AddonUninstallParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert("id".to_string(), Value::String(self.id.clone())); + Ok(data) + } +} + +impl ToMarionette<Map<String, Value>> for GeckoContextParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert( + "value".to_owned(), + serde_json::to_value(self.context.clone())?, + ); + Ok(data) + } +} + +impl ToMarionette<MarionettePrintParameters> for PrintParameters { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintParameters> { + Ok(MarionettePrintParameters { + orientation: self.orientation.to_marionette()?, + scale: self.scale, + background: self.background, + page: self.page.to_marionette()?, + margin: self.margin.to_marionette()?, + page_ranges: self.page_ranges.clone(), + shrink_to_fit: self.shrink_to_fit, + }) + } +} + +impl ToMarionette<MarionettePrintOrientation> for PrintOrientation { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintOrientation> { + Ok(match self { + PrintOrientation::Landscape => MarionettePrintOrientation::Landscape, + PrintOrientation::Portrait => MarionettePrintOrientation::Portrait, + }) + } +} + +impl ToMarionette<MarionettePrintPage> for PrintPage { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintPage> { + Ok(MarionettePrintPage { + width: self.width, + height: self.height, + }) + } +} + +impl ToMarionette<MarionettePrintMargins> for PrintMargins { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintMargins> { + Ok(MarionettePrintMargins { + top: self.top, + bottom: self.bottom, + left: self.left, + right: self.right, + }) + } +} + +impl ToMarionette<Map<String, Value>> for ActionsParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette<MarionetteCookie> for AddCookieParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteCookie> { + Ok(MarionetteCookie { + name: self.name.clone(), + value: self.value.clone(), + path: self.path.clone(), + domain: self.domain.clone(), + secure: self.secure, + http_only: self.httpOnly, + expiry: match &self.expiry { + Some(date) => Some(date.to_marionette()?), + None => None, + }, + same_site: self.sameSite.clone(), + }) + } +} + +impl ToMarionette<MarionetteDate> for Date { + fn to_marionette(&self) -> WebDriverResult<MarionetteDate> { + Ok(MarionetteDate(self.0)) + } +} + +impl ToMarionette<Map<String, Value>> for GetNamedCookieParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette<MarionetteUrl> for GetParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteUrl> { + Ok(MarionetteUrl { + url: self.url.clone(), + }) + } +} + +impl ToMarionette<MarionetteScript> for JavascriptCommandParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteScript> { + Ok(MarionetteScript { + script: self.script.clone(), + args: self.args.clone(), + }) + } +} + +impl ToMarionette<MarionetteLocator> for LocatorParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteLocator> { + Ok(MarionetteLocator { + using: self.using.to_marionette()?, + value: self.value.clone(), + }) + } +} + +impl ToMarionette<MarionetteSelector> for LocatorStrategy { + fn to_marionette(&self) -> WebDriverResult<MarionetteSelector> { + use self::LocatorStrategy::*; + match self { + CSSSelector => Ok(MarionetteSelector::CSS), + LinkText => Ok(MarionetteSelector::LinkText), + PartialLinkText => Ok(MarionetteSelector::PartialLinkText), + TagName => Ok(MarionetteSelector::TagName), + XPath => Ok(MarionetteSelector::XPath), + } + } +} + +impl ToMarionette<MarionetteNewWindow> for NewWindowParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteNewWindow> { + Ok(MarionetteNewWindow { + type_hint: self.type_hint.clone(), + }) + } +} + +impl ToMarionette<MarionetteKeys> for SendKeysParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteKeys> { + Ok(MarionetteKeys { + text: self.text.clone(), + value: self + .text + .chars() + .map(|x| x.to_string()) + .collect::<Vec<String>>(), + }) + } +} + +impl ToMarionette<MarionetteFrame> for SwitchToFrameParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteFrame> { + Ok(match &self.id { + Some(x) => match x { + FrameId::Short(n) => MarionetteFrame::Index(n.clone()), + FrameId::Element(el) => MarionetteFrame::Element(el.0.clone()), + }, + None => MarionetteFrame::Parent, + }) + } +} + +impl ToMarionette<Window> for SwitchToWindowParameters { + fn to_marionette(&self) -> WebDriverResult<Window> { + Ok(Window { + name: self.handle.clone(), + handle: self.handle.clone(), + }) + } +} + +impl ToMarionette<MarionetteTimeouts> for TimeoutsParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteTimeouts> { + Ok(MarionetteTimeouts { + implicit: self.implicit, + page_load: self.page_load, + script: self.script, + }) + } +} + +impl ToMarionette<LegacyWebElement> for WebElement { + fn to_marionette(&self) -> WebDriverResult<LegacyWebElement> { + Ok(LegacyWebElement { + id: self.to_string(), + }) + } +} + +impl ToMarionette<MarionetteWebElement> for WebElement { + fn to_marionette(&self) -> WebDriverResult<MarionetteWebElement> { + Ok(MarionetteWebElement { + element: self.to_string(), + }) + } +} + +impl ToMarionette<MarionetteWindowRect> for WindowRectParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteWindowRect> { + Ok(MarionetteWindowRect { + x: self.x, + y: self.y, + width: self.width, + height: self.height, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{MarionetteHandler, MarionetteSettings}; + use mozprofile::preferences::PrefValue; + use mozprofile::profile::Profile; + + // This is not a pretty test, mostly due to the nature of + // mozprofile's and MarionetteHandler's APIs, but we have had + // several regressions related to marionette.log.level. + #[test] + fn test_marionette_log_level() { + let mut profile = Profile::new().unwrap(); + let handler = MarionetteHandler::new(MarionetteSettings::default()); + handler.set_prefs(2828, &mut profile, false, vec![]).ok(); + let user_prefs = profile.user_prefs().unwrap(); + + let pref = user_prefs.get("marionette.log.level").unwrap(); + let value = match pref.value { + PrefValue::String(ref s) => s, + _ => panic!(), + }; + for (i, ch) in value.chars().enumerate() { + if i == 0 { + assert!(ch.is_uppercase()); + } else { + assert!(ch.is_lowercase()); + } + } + } +} diff --git a/testing/geckodriver/src/prefs.rs b/testing/geckodriver/src/prefs.rs new file mode 100644 index 0000000000..075d0c6809 --- /dev/null +++ b/testing/geckodriver/src/prefs.rs @@ -0,0 +1,160 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use mozprofile::preferences::Pref; + +// ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A GECKODRIVER PEER! +// +// All preferences in this file are not immediately effective, and +// require a restart of Firefox, or have to be set in the profile before +// Firefox gets started the first time. If a preference has to be added, +// which is immediately effective, it needs to be done in Marionette +// (marionette.js). +// +// Note: geckodriver is used out-of-tree with various builds of Firefox. +// Removing a preference from this file will cause regressions, +// so please be careful and get review from a Testing :: geckodriver peer +// before you make any changes to this file. +lazy_static! { + pub static ref DEFAULT: Vec<(&'static str, Pref)> = vec![ + // Make sure Shield doesn't hit the network. + ("app.normandy.api_url", Pref::new("")), + + // Disable Firefox old build background check + ("app.update.checkInstallTime", Pref::new(false)), + + // Disable automatically upgrading Firefox + // + // Note: Possible update tests could reset or flip the value to allow + // updates to be downloaded and applied. + ("app.update.disabledForTesting", Pref::new(true)), + // !!! For backward compatibility up to Firefox 64. Only remove + // when this Firefox version is no longer supported by geckodriver !!! + ("app.update.auto", Pref::new(false)), + + // Enable the dump function, which sends messages to the system + // console + ("browser.dom.window.dump.enabled", Pref::new(true)), + ("devtools.console.stdout.chrome", Pref::new(true)), + + // Disable safebrowsing components + ("browser.safebrowsing.blockedURIs.enabled", Pref::new(false)), + ("browser.safebrowsing.downloads.enabled", Pref::new(false)), + ("browser.safebrowsing.passwords.enabled", Pref::new(false)), + ("browser.safebrowsing.malware.enabled", Pref::new(false)), + ("browser.safebrowsing.phishing.enabled", Pref::new(false)), + + // Do not restore the last open set of tabs if the browser crashed + ("browser.sessionstore.resume_from_crash", Pref::new(false)), + + // Skip check for default browser on startup + ("browser.shell.checkDefaultBrowser", Pref::new(false)), + + // Do not redirect user when a milestone upgrade of Firefox + // is detected + ("browser.startup.homepage_override.mstone", Pref::new("ignore")), + + // Start with a blank page (about:blank) + ("browser.startup.page", Pref::new(0)), + + // Do not close the window when the last tab gets closed + // TODO: Remove once minimum supported Firefox release is 61. + ("browser.tabs.closeWindowWithLastTab", Pref::new(false)), + + // Do not warn when closing all open tabs + // TODO: Remove once minimum supported Firefox release is 61. + ("browser.tabs.warnOnClose", Pref::new(false)), + + // Disable the UI tour + ("browser.uitour.enabled", Pref::new(false)), + + // Do not warn on quitting Firefox + ("browser.warnOnQuit", Pref::new(false)), + + // Defensively disable data reporting systems + ("datareporting.healthreport.documentServerURI", Pref::new("http://%(server)s/dummy/healthreport/")), + ("datareporting.healthreport.logging.consoleEnabled", Pref::new(false)), + ("datareporting.healthreport.service.enabled", Pref::new(false)), + ("datareporting.healthreport.service.firstRun", Pref::new(false)), + ("datareporting.healthreport.uploadEnabled", Pref::new(false)), + + // Do not show datareporting policy notifications which can + // interfere with tests + ("datareporting.policy.dataSubmissionEnabled", Pref::new(false)), + ("datareporting.policy.dataSubmissionPolicyBypassNotification", Pref::new(true)), + + // Disable the ProcessHangMonitor + ("dom.ipc.reportProcessHangs", Pref::new(false)), + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + ("extensions.autoDisableScopes", Pref::new(0)), + ("extensions.enabledScopes", Pref::new(5)), + + // Disable intalling any distribution extensions or add-ons + ("extensions.installDistroAddons", Pref::new(false)), + + // Turn off extension updates so they do not bother tests + ("extensions.update.enabled", Pref::new(false)), + ("extensions.update.notifyUser", Pref::new(false)), + + // Allow the application to have focus even it runs in the + // background + ("focusmanager.testmode", Pref::new(true)), + + // Disable useragent updates + ("general.useragent.updates.enabled", Pref::new(false)), + + // Always use network provider for geolocation tests so we bypass + // the macOS dialog raised by the corelocation provider + ("geo.provider.testing", Pref::new(true)), + + // Do not scan wi-fi + ("geo.wifi.scan", Pref::new(false)), + + // No hang monitor + ("hangmonitor.timeout", Pref::new(0)), + + // Disable idle-daily notifications to avoid expensive operations + // that may cause unexpected test timeouts. + ("idle.lastDailyNotification", Pref::new(-1)), + + // Show chrome errors and warnings in the error console + ("javascript.options.showInConsole", Pref::new(true)), + + // Disable download and usage of OpenH264, and Widevine plugins + ("media.gmp-manager.updateEnabled", Pref::new(false)), + + // Disable the GFX sanity window + ("media.sanity-test.disabled", Pref::new(true)), + + // Do not prompt with long usernames or passwords in URLs + // TODO: Remove once minimum supported Firefox release is 61. + ("network.http.phishy-userpass-length", Pref::new(255)), + + // Do not automatically switch between offline and online + ("network.manage-offline-status", Pref::new(false)), + + // Make sure SNTP requests do not hit the network + ("network.sntp.pools", Pref::new("%(server)s")), + + // Disable Flash. The plugin container it is run in is + // causing problems when quitting Firefox from geckodriver, + // c.f. https://github.com/mozilla/geckodriver/issues/225. + ("plugin.state.flash", Pref::new(0)), + + // Don't do network connections for mitm priming + ("security.certerrors.mitm.priming.enabled", Pref::new(false)), + + // Ensure blocklist updates don't hit the network + ("services.settings.server", Pref::new("http://%(server)s/dummy/blocklist/")), + + // Disable first run pages + ("startup.homepage_welcome_url", Pref::new("about:blank")), + ("startup.homepage_welcome_url.additional", Pref::new("")), + + // Prevent starting into safe mode after application crashes + ("toolkit.startup.max_resumed_crashes", Pref::new(-1)), + ]; +} diff --git a/testing/geckodriver/src/test.rs b/testing/geckodriver/src/test.rs new file mode 100644 index 0000000000..e664aadf08 --- /dev/null +++ b/testing/geckodriver/src/test.rs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub fn assert_de<T>(data: &T, json: serde_json::Value) +where + T: std::fmt::Debug, + T: std::cmp::PartialEq, + T: serde::de::DeserializeOwned, +{ + assert_eq!(data, &serde_json::from_value::<T>(json).unwrap()); +} diff --git a/testing/geckodriver/src/tests/profile.zip b/testing/geckodriver/src/tests/profile.zip Binary files differnew file mode 100644 index 0000000000..286b118183 --- /dev/null +++ b/testing/geckodriver/src/tests/profile.zip |