From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- testing/geckodriver/src/android.rs | 533 ++++++++++ testing/geckodriver/src/browser.rs | 556 ++++++++++ testing/geckodriver/src/build.rs | 47 + testing/geckodriver/src/capabilities.rs | 1417 +++++++++++++++++++++++++ testing/geckodriver/src/command.rs | 343 ++++++ testing/geckodriver/src/logging.rs | 403 +++++++ testing/geckodriver/src/main.rs | 549 ++++++++++ testing/geckodriver/src/marionette.rs | 1623 +++++++++++++++++++++++++++++ testing/geckodriver/src/prefs.rs | 150 +++ testing/geckodriver/src/test.rs | 12 + testing/geckodriver/src/tests/profile.zip | Bin 0 -> 444 bytes 11 files changed, 5633 insertions(+) create mode 100644 testing/geckodriver/src/android.rs create mode 100644 testing/geckodriver/src/browser.rs create mode 100644 testing/geckodriver/src/build.rs create mode 100644 testing/geckodriver/src/capabilities.rs create mode 100644 testing/geckodriver/src/command.rs create mode 100644 testing/geckodriver/src/logging.rs create mode 100644 testing/geckodriver/src/main.rs create mode 100644 testing/geckodriver/src/marionette.rs create mode 100644 testing/geckodriver/src/prefs.rs create mode 100644 testing/geckodriver/src/test.rs create mode 100644 testing/geckodriver/src/tests/profile.zip (limited to 'testing/geckodriver/src') diff --git a/testing/geckodriver/src/android.rs b/testing/geckodriver/src/android.rs new file mode 100644 index 0000000000..10293d5c4e --- /dev/null +++ b/testing/geckodriver/src/android.rs @@ -0,0 +1,533 @@ +use crate::capabilities::AndroidOptions; +use mozdevice::{AndroidStorage, Device, Host, UnixPathBuf}; +use mozprofile::profile::Profile; +use serde::Serialize; +use serde_yaml::{Mapping, Value}; +use std::fmt; +use std::io; +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 MARIONETTE_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 = std::result::Result; + +#[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 for AndroidError { + fn from(value: io::Error) -> AndroidError { + AndroidError::IO(value) + } +} + +impl From for AndroidError { + fn from(value: mozdevice::DeviceError) -> AndroidError { + AndroidError::Device(value) + } +} + +impl From for AndroidError { + fn from(value: serde_yaml::Error) -> AndroidError { + AndroidError::Serde(value) + } +} + +impl From 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 { + Ok(AndroidProcess { + device, + package, + activity, + }) + } +} + +#[derive(Debug)] +pub struct AndroidHandler { + pub config: UnixPathBuf, + pub options: AndroidOptions, + pub process: AndroidProcess, + pub profile: UnixPathBuf, + pub test_root: UnixPathBuf, + + // Port forwarding for Marionette: host => target + pub marionette_host_port: u16, + pub marionette_target_port: u16, + + // Port forwarding for WebSocket connections (WebDriver BiDi and CDP) + pub websocket_port: Option, +} + +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.remove(&self.test_root) { + Ok(_) => debug!("Deleted test root folder: {}", &self.test_root.display()), + Err(e) => error!("Failed deleting test root folder: {}", e), + } + + match self + .process + .device + .kill_forward_port(self.marionette_host_port) + { + Ok(_) => debug!( + "Marionette port forward ({} -> {}) stopped", + &self.marionette_host_port, &self.marionette_target_port + ), + Err(e) => error!( + "Marionette port forward ({} -> {}) failed to stop: {}", + &self.marionette_host_port, &self.marionette_target_port, e + ), + } + + if let Some(port) = self.websocket_port { + match self.process.device.kill_forward_port(port) { + Ok(_) => debug!("WebSocket port forward ({0} -> {0}) stopped", &port), + Err(e) => error!( + "WebSocket port forward ({0} -> {0}) failed to stop: {1}", + &port, e + ), + } + } + } +} + +impl AndroidHandler { + pub fn new( + options: &AndroidOptions, + marionette_host_port: u16, + websocket_port: Option, + ) -> Result { + // 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 forwarding for Marionette. + device.forward_port(marionette_host_port, MARIONETTE_TARGET_PORT)?; + debug!( + "Marionette port forward ({} -> {}) started", + marionette_host_port, MARIONETTE_TARGET_PORT + ); + + if let Some(port) = websocket_port { + // Set up port forwarding for WebSocket connections (WebDriver BiDi, and CDP). + device.forward_port(port, port)?; + debug!("WebSocket port forward ({} -> {}) started", port, port); + } + + let test_root = match device.storage { + AndroidStorage::App => { + device.run_as_package = Some(options.package.to_owned()); + let mut buf = UnixPathBuf::from("/data/data"); + buf.push(&options.package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => { + // We need to push the profile to a location on the device that can also + // be read and write by the application, and works for unrooted devices. + // The only location that meets this criteria is under: + // $EXTERNAL_STORAGE/Android/data/%options.package%/files + let response = device.execute_host_shell_command("echo $EXTERNAL_STORAGE")?; + let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); + buf.push("Android/data"); + buf.push(&options.package); + buf.push("files/test_root"); + buf + } + }; + + 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 mut packages = response + .trim() + .split_terminator('\n') + .filter(|line| line.starts_with("package:")) + .map(|line| line.rsplit(':').next().expect("Package name found")); + if !packages.any(|x| x == options.package.as_str()) { + return Err(AndroidError::PackageNotFound(options.package.clone())); + } + + let config = UnixPathBuf::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::>(); + 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 { + config, + process, + profile, + test_root, + marionette_host_port, + marionette_target_port: MARIONETTE_TARGET_PORT, + options: options.clone(), + websocket_port, + }) + } + + pub fn generate_config_file( + &self, + args: Option>, + envs: I, + ) -> Result + where + I: IntoIterator, + 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, Eq, Debug)] + pub struct Config { + pub env: Mapping, + pub args: Vec, + } + + let mut config = Config { + args: vec![ + "--marionette".into(), + "--profile".into(), + self.profile.display().to_string(), + ], + env: Mapping::new(), + }; + + config.args.append(&mut args.unwrap_or_default()); + + 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 = vec![CONFIG_FILE_HEADING.to_owned()]; + contents.push(serde_yaml::to_string(&config)?); + + Ok(contents.concat()) + } + + pub fn prepare( + &self, + profile: &Profile, + args: Option>, + env: I, + ) -> Result<()> + where + I: IntoIterator, + 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(args, 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, UnixPathBuf}; + + fn run_handler_storage_test(package: &str, storage: AndroidStorageInput) { + let options = AndroidOptions::new(package.to_owned(), storage); + let handler = AndroidHandler::new(&options, 4242, None).expect("has valid Android handler"); + + assert_eq!(handler.options, options); + assert_eq!(handler.process.package, package); + + let expected_config_path = UnixPathBuf::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 = UnixPathBuf::from("/data/data"); + buf.push(&package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => { + let response = handler + .process + .device + .execute_host_shell_command("echo $EXTERNAL_STORAGE") + .unwrap(); + + let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); + buf.push("Android/data/"); + buf.push(&package); + buf.push("files/test_root"); + buf + } + }; + assert_eq!(handler.test_root, test_root); + + let mut profile = test_root; + 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/browser.rs b/testing/geckodriver/src/browser.rs new file mode 100644 index 0000000000..a33a755fa2 --- /dev/null +++ b/testing/geckodriver/src/browser.rs @@ -0,0 +1,556 @@ +/* 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::capabilities::{FirefoxOptions, ProfileType}; +use crate::logging; +use crate::prefs; +use mozprofile::preferences::Pref; +use mozprofile::profile::{PrefFile, Profile}; +use mozrunner::runner::{FirefoxProcess, FirefoxRunner, Runner, RunnerProcess}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; + +/// A running Gecko instance. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Browser { + Local(LocalBrowser), + Remote(RemoteBrowser), + + /// An existing browser instance not controlled by GeckoDriver + Existing(u16), +} + +impl Browser { + pub(crate) fn close(self, wait_for_shutdown: bool) -> WebDriverResult<()> { + match self { + Browser::Local(x) => x.close(wait_for_shutdown), + Browser::Remote(x) => x.close(), + Browser::Existing(_) => Ok(()), + } + } + + pub(crate) fn marionette_port(&mut self) -> WebDriverResult> { + match self { + Browser::Local(x) => x.marionette_port(), + Browser::Remote(x) => x.marionette_port(), + Browser::Existing(x) => Ok(Some(*x)), + } + } + + pub(crate) fn update_marionette_port(&mut self, port: u16) { + match self { + Browser::Local(x) => x.update_marionette_port(port), + Browser::Remote(x) => x.update_marionette_port(port), + Browser::Existing(x) => { + if port != *x { + error!( + "Cannot re-assign Marionette port when connected to an existing browser" + ); + } + } + } + } +} + +#[derive(Debug)] +/// A local Firefox process, running on this (host) device. +pub(crate) struct LocalBrowser { + marionette_port: u16, + prefs_backup: Option, + process: FirefoxProcess, + profile_path: Option, +} + +impl LocalBrowser { + pub(crate) fn new( + options: FirefoxOptions, + marionette_port: u16, + jsdebugger: bool, + profile_root: Option<&Path>, + ) -> 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 = matches!(options.profile, ProfileType::Path(_)); + + let mut profile = match options.profile { + ProfileType::Named => None, + ProfileType::Path(x) => Some(x), + ProfileType::Temporary => Some(Profile::new(profile_root)?), + }; + + let (profile_path, prefs_backup) = if let Some(ref mut profile) = profile { + let profile_path = profile.path.clone(); + let prefs_backup = set_prefs( + marionette_port, + profile, + is_custom_profile, + options.prefs, + jsdebugger, + ) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + (Some(profile_path), prefs_backup) + } else { + warn!("Unable to set geckodriver prefs when using a named profile"); + (None, None) + }; + + let mut runner = FirefoxRunner::new(&binary, profile); + + runner.arg("--marionette"); + if 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 process = match runner.start() { + Ok(process) => process, + Err(e) => { + if let Some(backup) = prefs_backup { + backup.restore(); + } + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to start browser {}: {}", binary.display(), e), + )); + } + }; + + Ok(LocalBrowser { + marionette_port, + prefs_backup, + process, + profile_path, + }) + } + + fn close(mut self, wait_for_shutdown: bool) -> WebDriverResult<()> { + if wait_for_shutdown { + // TODO(https://bugzil.la/1443922): + // Use toolkit.asyncshutdown.crash_timout pref + let duration = time::Duration::from_secs(70); + match self.process.wait(duration) { + Ok(x) => debug!("Browser process stopped: {}", x), + Err(e) => error!("Failed to stop browser process: {}", e), + } + } + self.process.kill()?; + + // Restoring the prefs if the browser fails to stop perhaps doesn't work anyway + if let Some(prefs_backup) = self.prefs_backup { + prefs_backup.restore(); + }; + + Ok(()) + } + + fn marionette_port(&mut self) -> WebDriverResult> { + if self.marionette_port != 0 { + return Ok(Some(self.marionette_port)); + } + + if let Some(profile_path) = self.profile_path.as_ref() { + return Ok(read_marionette_port(profile_path)); + } + + // This should be impossible, but it isn't enforced + Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Port not known when using named profile", + )) + } + + fn update_marionette_port(&mut self, port: u16) { + self.marionette_port = port; + } + + pub(crate) fn check_status(&mut self) -> Option { + match self.process.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()), + } + } +} + +fn read_marionette_port(profile_path: &Path) -> Option { + let port_file = profile_path.join("MarionetteActivePort"); + let mut port_str = String::with_capacity(6); + let mut file = match fs::File::open(&port_file) { + Ok(file) => file, + Err(_) => { + trace!("Failed to open {}", &port_file.to_string_lossy()); + return None; + } + }; + if let Err(e) = file.read_to_string(&mut port_str) { + trace!("Failed to read {}: {}", &port_file.to_string_lossy(), e); + return None; + }; + println!("Read port: {}", port_str); + let port = port_str.parse::().ok(); + if port.is_none() { + warn!("Failed fo convert {} to u16", &port_str); + } + port +} + +#[derive(Debug)] +/// A remote instance, running on a (target) Android device. +pub(crate) struct RemoteBrowser { + handler: AndroidHandler, + marionette_port: u16, + prefs_backup: Option, +} + +impl RemoteBrowser { + pub(crate) fn new( + options: FirefoxOptions, + marionette_port: u16, + websocket_port: Option, + profile_root: Option<&Path>, + ) -> WebDriverResult { + let android_options = options.android.unwrap(); + + let handler = AndroidHandler::new(&android_options, marionette_port, websocket_port)?; + + // Profile management. + let (mut profile, is_custom_profile) = match options.profile { + ProfileType::Named => { + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Cannot use a named profile on Android", + )); + } + ProfileType::Path(x) => (x, true), + ProfileType::Temporary => (Profile::new(profile_root)?, false), + }; + + let prefs_backup = set_prefs( + handler.marionette_target_port, + &mut profile, + is_custom_profile, + options.prefs, + false, + ) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + + handler.prepare(&profile, options.args, options.env.unwrap_or_default())?; + + handler.launch()?; + + Ok(RemoteBrowser { + handler, + marionette_port, + prefs_backup, + }) + } + + fn close(self) -> WebDriverResult<()> { + self.handler.force_stop()?; + + // Restoring the prefs if the browser fails to stop perhaps doesn't work anyway + if let Some(prefs_backup) = self.prefs_backup { + prefs_backup.restore(); + }; + + Ok(()) + } + + fn marionette_port(&mut self) -> WebDriverResult> { + Ok(Some(self.marionette_port)) + } + + fn update_marionette_port(&mut self, port: u16) { + self.marionette_port = port; + } +} + +fn set_prefs( + port: u16, + profile: &mut Profile, + custom_profile: bool, + extra_prefs: Vec<(String, Pref)>, + js_debugger: bool, +) -> WebDriverResult> { + let prefs = profile.user_prefs().map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Unable to read profile preferences file", + ) + })?; + + let backup_prefs = if custom_profile && prefs.path.exists() { + Some(PrefsBackup::new(prefs)?) + } else { + None + }; + + for &(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 js_debugger { + 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.port", Pref::new(port)); + prefs.insert("remote.log.level", logging::max_level().into()); + + prefs.write().map_err(|e| { + WebDriverError::new( + ErrorStatus::UnknownError, + format!("Unable to write Firefox profile: {}", e), + ) + })?; + Ok(backup_prefs) +} + +#[derive(Debug)] +struct PrefsBackup { + orig_path: PathBuf, + backup_path: PathBuf, +} + +impl PrefsBackup { + fn new(prefs: &PrefFile) -> WebDriverResult { + let mut prefs_backup_path = prefs.path.clone(); + let mut counter = 0; + while { + let ext = if counter > 0 { + format!("geckodriver_backup_{}", counter) + } else { + "geckodriver_backup".to_string() + }; + prefs_backup_path.set_extension(ext); + prefs_backup_path.exists() + } { + counter += 1 + } + debug!("Backing up prefs to {:?}", prefs_backup_path); + fs::copy(&prefs.path, &prefs_backup_path)?; + + Ok(PrefsBackup { + orig_path: prefs.path.clone(), + backup_path: prefs_backup_path, + }) + } + + fn restore(self) { + if self.backup_path.exists() { + let _ = fs::rename(self.backup_path, self.orig_path); + } + } +} + +#[cfg(test)] +mod tests { + use super::set_prefs; + use crate::browser::read_marionette_port; + use crate::capabilities::{FirefoxOptions, ProfileType}; + use base64::prelude::BASE64_STANDARD; + use base64::Engine; + use mozprofile::preferences::{Pref, PrefValue}; + use mozprofile::profile::Profile; + use serde_json::{Map, Value}; + use std::fs::File; + use std::io::{Read, Write}; + use std::path::Path; + use tempfile::tempdir; + + 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_STANDARD.encode(&profile_data)) + } + + // 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 remote.log.level. + #[test] + fn test_remote_log_level() { + let mut profile = Profile::new(None).unwrap(); + set_prefs(2828, &mut profile, false, vec![], false).ok(); + let user_prefs = profile.user_prefs().unwrap(); + + let pref = user_prefs.get("remote.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()); + } + } + } + + #[test] + fn test_prefs() { + let marionette_settings = Default::default(); + + let encoded_profile = example_profile(); + let mut prefs: Map = Map::new(); + prefs.insert( + "browser.display.background_color".into(), + Value::String("#00ff00".into()), + ); + + let mut firefox_opts = Map::new(); + firefox_opts.insert("profile".into(), encoded_profile); + firefox_opts.insert("prefs".into(), Value::Object(prefs)); + + let mut caps = Map::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid profile and prefs"); + + let mut profile = match opts.profile { + ProfileType::Path(profile) => profile, + _ => panic!("Expected ProfileType::Path"), + }; + + set_prefs(2828, &mut profile, true, opts.prefs, false).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))); + } + + #[test] + fn test_pref_backup() { + let mut profile = Profile::new(None).unwrap(); + + // Create some prefs in the profile + let initial_prefs = profile.user_prefs().unwrap(); + initial_prefs.insert("geckodriver.example", Pref::new("example")); + initial_prefs.write().unwrap(); + + let prefs_path = initial_prefs.path.clone(); + + let mut conflicting_backup_path = initial_prefs.path.clone(); + conflicting_backup_path.set_extension("geckodriver_backup"); + println!("{:?}", conflicting_backup_path); + let mut file = File::create(&conflicting_backup_path).unwrap(); + file.write_all(b"test").unwrap(); + assert!(conflicting_backup_path.exists()); + + let mut initial_prefs_data = String::new(); + File::open(&prefs_path) + .expect("Initial prefs exist") + .read_to_string(&mut initial_prefs_data) + .unwrap(); + + let backup = set_prefs(2828, &mut profile, true, vec![], false) + .unwrap() + .unwrap(); + let user_prefs = profile.user_prefs().unwrap(); + + assert!(user_prefs.path.exists()); + let mut backup_path = user_prefs.path.clone(); + backup_path.set_extension("geckodriver_backup_1"); + + assert!(backup_path.exists()); + + // Ensure the actual prefs contain both the existing ones and the ones we added + let pref = user_prefs.get("marionette.port").unwrap(); + assert_eq!(pref.value, PrefValue::Int(2828)); + + let pref = user_prefs.get("geckodriver.example").unwrap(); + assert_eq!(pref.value, PrefValue::String("example".into())); + + // Ensure the backup prefs don't contain the new settings + let mut backup_data = String::new(); + File::open(&backup_path) + .expect("Backup prefs exist") + .read_to_string(&mut backup_data) + .unwrap(); + assert_eq!(backup_data, initial_prefs_data); + + backup.restore(); + + assert!(!backup_path.exists()); + let mut final_prefs_data = String::new(); + File::open(&prefs_path) + .expect("Initial prefs exist") + .read_to_string(&mut final_prefs_data) + .unwrap(); + assert_eq!(final_prefs_data, initial_prefs_data); + } + + #[test] + fn test_local_read_marionette_port() { + fn create_port_file(profile_path: &Path, data: &[u8]) { + let port_path = profile_path.join("MarionetteActivePort"); + let mut file = File::create(&port_path).unwrap(); + file.write_all(data).unwrap(); + } + + let profile_dir = tempdir().unwrap(); + let profile_path = profile_dir.path(); + assert_eq!(read_marionette_port(profile_path), None); + assert_eq!(read_marionette_port(profile_path), None); + create_port_file(profile_path, b""); + assert_eq!(read_marionette_port(profile_path), None); + create_port_file(profile_path, b"1234"); + assert_eq!(read_marionette_port(profile_path), Some(1234)); + create_port_file(profile_path, b"1234abc"); + assert_eq!(read_marionette_port(profile_path), None); + } +} diff --git a/testing/geckodriver/src/build.rs b/testing/geckodriver/src/build.rs new file mode 100644 index 0000000000..f77a3363fa --- /dev/null +++ b/testing/geckodriver/src/build.rs @@ -0,0 +1,47 @@ +/* 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(()) + } +} + +impl From for Value { + fn from(_: BuildInfo) -> 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..eda75ad62b --- /dev/null +++ b/testing/geckodriver/src/capabilities.rs @@ -0,0 +1,1417 @@ +/* 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 crate::marionette::MarionetteSettings; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use mozdevice::AndroidStorageInput; +use mozprofile::preferences::Pref; +use mozprofile::profile::Profile; +use mozrunner::firefox_args::{get_arg_value, parse_args, Arg}; +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::ffi::OsString; +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}; + +#[derive(Clone, Debug)] +enum VersionError { + VersionError(mozversion::Error), + MissingBinary, +} + +impl From 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 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, + fallback_binary: Option<&'a PathBuf>, + version_cache: BTreeMap>, +} + +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) { + 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 { + 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 { + 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 { + 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> { + Ok(Some("firefox".into())) + } + + fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult> { + 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> { + 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 { + Ok(true) + } + + fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult { + Ok(true) + } + + fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult { + Ok(true) + } + + fn compare_browser_version( + &mut self, + version: &str, + comparison: &str, + ) -> WebDriverResult { + Version::from_str(version) + .map_err(VersionError::from)? + .matches(comparison) + .map_err(|err| VersionError::from(err).into()) + } + + fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult { + Ok(true) + } + + fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult { + 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" => { + warn!("You are using the deprecated vendor specific capability 'moz:useNonSpecCompliantPointerOrigin', which will be removed in Firefox 116."); + 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 { + 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, + pub device_serial: Option, + pub intent_arguments: Option>, + pub package: String, + pub storage: AndroidStorageInput, +} + +impl AndroidOptions { + pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions { + AndroidOptions { + package, + storage, + ..Default::default() + } + } +} + +#[derive(Debug, PartialEq)] +pub enum ProfileType { + Path(Profile), + Named, + Temporary, +} + +impl Default for ProfileType { + fn default() -> Self { + ProfileType::Temporary + } +} + +/// 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, + pub profile: ProfileType, + pub args: Option>, + pub env: Option>, + pub log: LogOptions, + pub prefs: Vec<(String, Pref)>, + pub android: Option, + pub use_websocket: bool, +} + +impl FirefoxOptions { + pub fn new() -> FirefoxOptions { + Default::default() + } + + pub(crate) fn from_capabilities( + binary_path: Option, + settings: &MarionetteSettings, + matched: &mut Capabilities, + ) -> WebDriverResult { + 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", + ) + })?; + + if options.get("androidPackage").is_some() && options.get("binary").is_some() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage and binary are mutual exclusive", + )); + } + + rv.android = FirefoxOptions::load_android(settings.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)?; + if let Some(profile) = + FirefoxOptions::load_profile(settings.profile_root.as_deref(), options)? + { + rv.profile = ProfileType::Path(profile); + } + } + + if let Some(args) = rv.args.as_ref() { + let os_args = parse_args(args.iter().map(OsString::from).collect::>().iter()); + + if let Some(path) = get_arg_value(os_args.iter(), Arg::Profile) { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a --profile argument and a profile", + )); + } + let path_buf = PathBuf::from(path); + rv.profile = ProfileType::Path(Profile::new_from_path(&path_buf)?); + } + + if get_arg_value(os_args.iter(), Arg::NamedProfile).is_some() { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a -P argument and a profile", + )); + } + // See bug 1757720 + warn!("Firefox was configured to use a named profile (`-P `). \ + Support for named profiles will be removed in a future geckodriver release. \ + Please instead use the `--profile ` Firefox argument to start with an existing profile"); + rv.profile = ProfileType::Named; + } + + // Block these Firefox command line arguments that should not be settable + // via session capabilities. + if let Some(arg) = os_args + .iter() + .filter_map(|(opt_arg, _)| opt_arg.as_ref()) + .find(|arg| { + matches!( + arg, + Arg::Marionette + | Arg::RemoteAllowHosts + | Arg::RemoteAllowOrigins + | Arg::RemoteDebuggingPort + ) + }) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Argument {} can't be set via capabilities", arg), + )); + }; + } + + let has_web_socket_url = matched + .get("webSocketUrl") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + let has_debugger_address = matched + .remove("moz:debuggerAddress") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + // Set a command line provided port for the Remote Agent for now. + // It needs to be the same on the host and the Android device. + if has_web_socket_url || has_debugger_address { + rv.use_websocket = true; + + // Bug 1722863: Setting of command line arguments would be + // better suited in the individual Browser implementations. + let mut remote_args = Vec::new(); + remote_args.push("--remote-debugging-port".to_owned()); + remote_args.push(settings.websocket_port.to_string()); + + // Handle additional hosts for WebDriver BiDi WebSocket connections + if !settings.allow_hosts.is_empty() { + remote_args.push("--remote-allow-hosts".to_owned()); + remote_args.push( + settings + .allow_hosts + .iter() + .map(|host| host.to_string()) + .collect::>() + .join(","), + ); + } + + // Handle additional origins for WebDriver BiDi WebSocket connections + if !settings.allow_origins.is_empty() { + remote_args.push("--remote-allow-origins".to_owned()); + remote_args.push( + settings + .allow_origins + .iter() + .map(|origin| origin.to_string()) + .collect::>() + .join(","), + ); + } + + if let Some(ref mut args) = rv.args { + args.append(&mut remote_args); + } else { + rv.args = Some(remote_args); + } + } + + Ok(rv) + } + + fn load_profile( + profile_root: Option<&Path>, + options: &Capabilities, + ) -> WebDriverResult> { + 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_STANDARD.decode(profile_base64)?; + + // Create an emtpy profile directory + let profile = Profile::new(profile_root)?; + 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>> { + 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::>>() + .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>> { + 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 { + 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> { + 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> { + 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.clone(), 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 => { + match package.as_str() { + "org.mozilla.firefox" + | "org.mozilla.firefox_beta" + | "org.mozilla.fenix" + | "org.mozilla.fenix.debug" + | "org.mozilla.reference.browser" => { + Some("org.mozilla.fenix.IntentReceiverActivity".to_string()) + } + "org.mozilla.focus" + | "org.mozilla.focus.debug" + | "org.mozilla.klar" + | "org.mozilla.klar.debug" => { + Some("org.mozilla.focus.activity.IntentReceiverActivity".to_string()) + } + // For all other applications fallback to auto-detection. + _ => 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::>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments entries are not all strings", + ) + })?; + + Some(args) + } + None => { + // All GeckoView based applications support this view, + // and allow to open a blank page in a Gecko window. + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + } + }; + + Ok(Some(android)) + } else { + Ok(None) + } + } +} + +fn pref_from_json(value: &Value) -> WebDriverResult { + 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 serde_json::{json, Map, Value}; + use std::fs::File; + use std::io::Read; + use url::{Host, Url}; + 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_STANDARD.encode(&profile_data)) + } + + fn make_options( + firefox_opts: Capabilities, + marionette_settings: Option, + ) -> WebDriverResult { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + FirefoxOptions::from_capabilities(None, &marionette_settings.unwrap_or_default(), &mut caps) + } + + #[test] + fn fx_options_default() { + let opts: FirefoxOptions = Default::default(); + 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_empty_caps() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("valid firefox options"); + 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 marionette_settings = Default::default(); + + let opts = FirefoxOptions::from_capabilities( + Some(binary.clone()), + &marionette_settings, + &mut caps, + ) + .expect("valid firefox options"); + 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_blocked_firefox_arguments() { + let blocked_args = vec![ + "--marionette", + "--remote-allow-hosts", + "--remote-allow-origins", + "--remote-debugging-port", + ]; + + for arg in blocked_args { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!([arg])); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_not_set() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_false() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(false)); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_true() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_hosts() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_hosts = vec![ + Host::parse("foo").expect("host"), + Host::parse("bar").expect("host"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + assert_eq!(iter.next(), Some(&"foo,bar".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_origins() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_origins = vec![ + Url::parse("http://foo/").expect("url"), + Url::parse("http://bar/").expect("url"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + assert_eq!(iter.next(), Some(&"http://foo/,http://bar/".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_not_set() { + let caps = Capabilities::new(); + + let opts = make_options(caps, None).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 = make_options(caps, None).expect("valid firefox options"); + assert!( + opts.args.is_none(), + "CLI arguments for Firefox 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 settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_invalid_caps() { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), json!(42)); + + let marionette_settings = Default::default(); + FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect_err("Firefox options need to be of type object"); + } + + #[test] + fn fx_options_android_package_and_binary() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo")); + firefox_opts.insert("binary".into(), json!("bar")); + + make_options(firefox_opts, None) + .expect_err("androidPackage and binary are mutual exclusive"); + } + + #[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, None).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, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().package, value.to_string()); + } + } + + #[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, None).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, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_android_activity_default_known_apps() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.focus", + "org.mozilla.focus.debug", + "org.mozilla.klar", + "org.mozilla.klar.debug", + "org.mozilla.reference.browser", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert!(opts + .android + .unwrap() + .activity + .unwrap() + .contains("IntentReceiverActivity")); + } + } + + #[test] + fn fx_options_android_activity_default_unknown_apps() { + let packages = vec!["org.mozilla.geckoview_example", "com.some.other.app"]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert( + "androidPackage".into(), + json!("org.mozilla.geckoview_example"), + ); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + #[test] + fn fx_options_android_activity_override() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, Some("foo".to_string())); + } + + #[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, None).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, None).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, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().device_serial, + Some("cheese".to_string()) + ); + } + + #[test] + fn fx_options_android_device_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, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_defaults() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.geckoview_example", + "org.mozilla.reference.browser", + "com.some.other.app", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + ); + } + } + + #[test] + fn fx_options_android_intent_arguments_override() { + 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, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec!["lorem".to_string(), "ipsum".to_string()]) + ); + } + + #[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, None).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, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env() { + let mut env: Map = 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, None).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); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env_invalid_value() { + let mut env: Map = 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, None).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, None).expect("valid firefox options"); + let mut profile = match opts.profile { + ProfileType::Path(profile) => profile, + _ => panic!("Expected ProfileType::Path"), + }; + 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 fx_options_args_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Path(_))); + } + + #[test] + fn fx_options_args_named_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Named)); + } + + #[test] + fn fx_options_args_no_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--headless"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Temporary)); + } + + #[test] + fn fx_options_args_profile_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } + + #[test] + fn fx_options_args_p_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } +} diff --git a/testing/geckodriver/src/command.rs b/testing/geckodriver/src/command.rs new file mode 100644 index 0000000000..c92eabf6f3 --- /dev/null +++ b/testing/geckodriver/src/command.rs @@ -0,0 +1,343 @@ +/* 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::prelude::BASE64_STANDARD; +use base64::Engine; +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 fn extension_routes() -> Vec<(Method, &'static str, GeckoExtensionRoute)> { + 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, Eq)] +pub enum GeckoExtensionRoute { + GetContext, + SetContext, + InstallAddon, + UninstallAddon, + TakeFullScreenshot, +} + +impl WebDriverExtensionRoute for GeckoExtensionRoute { + type Command = GeckoExtensionCommand; + + fn command( + &self, + _params: &Parameters, + body_data: &Value, + ) -> WebDriverResult> { + 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 { + 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, Eq, Serialize)] +pub struct AddonInstallParameters { + pub path: String, + pub temporary: Option, +} + +impl<'de> Deserialize<'de> for AddonInstallParameters { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Base64 { + addon: String, + temporary: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Path { + path: String, + temporary: Option, + } + + #[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_STANDARD + .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, Eq, Serialize, Deserialize)] +pub struct AddonUninstallParameters { + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GeckoContext { + Content, + Chrome, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GeckoContextParameters { + pub context: GeckoContext, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct XblLocatorParameters { + pub name: String, + pub value: String, +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct LogOptions { + pub level: Option, +} + +#[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::("").is_err()); + assert!(serde_json::from_value::(json!(null)).is_err()); + assert!(serde_json::from_value::(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::(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::(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::(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::(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::(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::(json).is_err()); + } + + #[test] + fn test_json_install_parameters_with_temporary_only() { + let json = json!({"temporary": true}); + assert!(serde_json::from_value::(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::(json).is_err()); + } + + #[test] + fn test_json_addon_uninstall_parameters_invalid() { + assert!(serde_json::from_str::("").is_err()); + assert!(serde_json::from_value::(json!(null)).is_err()); + assert!(serde_json::from_value::(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::(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::

(json!({})).is_err()); + assert!(serde_json::from_value::

(json!({ "context": null })).is_err()); + assert!(serde_json::from_value::

(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..073da956ed --- /dev/null +++ b/testing/geckodriver/src/logging.rs @@ -0,0 +1,403 @@ +/* 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.sys.mjs] 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.sys.mjs]: https://searchfox.org/mozilla-central/source/toolkit/modules/Log.sys.mjs +//! [`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::{AtomicBool, AtomicUsize, Ordering}; +use unicode_segmentation::UnicodeSegmentation; + +use mozprofile::preferences::Pref; + +static LOG_TRUNCATE: AtomicBool = AtomicBool::new(true); +static MAX_LOG_LEVEL: AtomicUsize = AtomicUsize::new(0); + +const MAX_STRING_LENGTH: usize = 250; + +const LOGGED_TARGETS: &[&str] = &[ + "geckodriver", + "mozdevice", + "mozprofile", + "mozrunner", + "mozversion", + "webdriver", +]; + +/// Logger levels from [Log.sys.mjs]. +/// +/// [Log.sys.mjs]: https://searchfox.org/mozilla-central/source/toolkit/modules/Log.sys.mjs +#[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 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 { + 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 From for log::Level { + fn from(level: Level) -> log::Level { + use self::Level::*; + match level { + Fatal | Error => log::Level::Error, + Warn => log::Level::Warn, + Info => log::Level::Info, + Config | Debug => log::Level::Debug, + Trace => log::Level::Trace, + } + } +} + +impl From for Pref { + fn from(level: Level) -> Pref { + use self::Level::*; + Pref::new(match level { + Fatal => "Fatal", + Error => "Error", + Warn => "Warn", + Info => "Info", + Config => "Config", + Debug => "Debug", + Trace => "Trace", + }) + } +} + +impl From 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()) { + if let Some((s1, s2)) = truncate_message(record.args()) { + println!( + "{}\t{}\t{}\t{} ... {}", + format_ts(chrono::Local::now()), + record.target(), + record.level(), + s1, + s2 + ); + } else { + println!( + "{}\t{}\t{}\t{}", + format_ts(chrono::Local::now()), + 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(truncate: bool) -> Result<(), log::SetLoggerError> { + init_with_level(Level::Info, truncate) +} + +/// Initialises the logging subsystem. +pub fn init_with_level(level: Level, truncate: bool) -> Result<(), log::SetLoggerError> { + let logger = Logger {}; + set_max_level(level); + set_truncate(truncate); + 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()) +} + +/// Sets the global maximum log level. +pub fn set_truncate(truncate: bool) { + LOG_TRUNCATE.store(truncate, Ordering::SeqCst); +} + +/// Returns the truncation flag. +pub fn truncate() -> bool { + LOG_TRUNCATE.load(Ordering::Relaxed) +} + +/// Produces a 13-digit Unix Epoch timestamp similar to Gecko. +fn format_ts(ts: chrono::DateTime) -> String { + format!("{}{:03}", ts.timestamp(), ts.timestamp_subsec_millis()) +} + +/// Truncate a log message if it's too long +fn truncate_message(args: &fmt::Arguments) -> Option<(String, String)> { + // Don't truncate the message if requested. + if !truncate() { + return None; + } + + let message = format!("{}", args); + let chars = message.graphemes(true).collect::>(); + + if chars.len() > MAX_STRING_LENGTH { + let middle: usize = MAX_STRING_LENGTH / 2; + let s1 = chars[0..middle].concat(); + let s2 = chars[chars.len() - middle..].concat(); + Some((s1, s2)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::str::FromStr; + use std::sync::Mutex; + + 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_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::::into(Level::Fatal), log::Level::Error); + assert_eq!(Into::::into(Level::Error), log::Level::Error); + assert_eq!(Into::::into(Level::Warn), log::Level::Warn); + assert_eq!(Into::::into(Level::Info), log::Level::Info); + assert_eq!(Into::::into(Level::Config), log::Level::Debug); + assert_eq!(Into::::into(Level::Debug), log::Level::Debug); + assert_eq!(Into::::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::::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, false).unwrap(); + assert_eq!(max_level(), Level::Debug); + assert!(init_with_level(Level::Warn, false).is_err()); + } + + #[test] + fn test_format_ts() { + let ts = chrono::Local::now(); + let s = format_ts(ts); + assert_eq!(s.len(), 13); + } + + #[test] + fn test_truncate() { + let short_message = (0..MAX_STRING_LENGTH).map(|_| "x").collect::(); + // A message up to MAX_STRING_LENGTH is not truncated + assert_eq!(truncate_message(&format_args!("{}", short_message)), None); + + let long_message = (0..MAX_STRING_LENGTH + 1).map(|_| "x").collect::(); + let part = (0..MAX_STRING_LENGTH / 2).map(|_| "x").collect::(); + + // A message longer than MAX_STRING_LENGTH is not truncated if requested + set_truncate(false); + assert_eq!(truncate_message(&format_args!("{}", long_message)), None); + + // A message longer than MAX_STRING_LENGTH is truncated if requested + set_truncate(true); + assert_eq!( + truncate_message(&format_args!("{}", long_message)), + Some((part.to_owned(), part)) + ); + } +} diff --git a/testing/geckodriver/src/main.rs b/testing/geckodriver/src/main.rs new file mode 100644 index 0000000000..64df65a0d0 --- /dev/null +++ b/testing/geckodriver/src/main.rs @@ -0,0 +1,549 @@ +#![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 tempfile; +extern crate url; +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, ToSocketAddrs}; +use std::path::PathBuf; +use std::result; +use std::str::FromStr; + +use clap::{AppSettings, Arg, Command}; + +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 browser; +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; +use url::{Host, Url}; + +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 { + matches!(*self, FatalError::Parsing(_)) + } +} + +impl From for FatalError { + fn from(err: clap::Error) -> FatalError { + FatalError::Parsing(err) + } +} + +impl From 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), + }; + 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 = result::Result; + +#[allow(clippy::large_enum_variant)] +enum Operation { + Help, + Version, + Server { + log_level: Option, + log_truncate: bool, + address: SocketAddr, + allow_hosts: Vec, + allow_origins: Vec, + settings: MarionetteSettings, + deprecated_storage_arg: bool, + }, +} + +/// Get a socket address from the provided host and port +/// +/// # Arguments +/// * `webdriver_host` - The hostname on which the server will listen +/// * `webdriver_port` - The port on which the server will listen +/// +/// When the host and port resolve to multiple addresses, prefer +/// IPv4 addresses vs IPv6. +fn server_address(webdriver_host: &str, webdriver_port: u16) -> ProgramResult { + let mut socket_addrs = match format!("{}:{}", webdriver_host, webdriver_port).to_socket_addrs() + { + Ok(addrs) => addrs.collect::>(), + Err(e) => usage!("{}: {}:{}", e, webdriver_host, webdriver_port), + }; + if socket_addrs.is_empty() { + usage!( + "Unable to resolve host: {}:{}", + webdriver_host, + webdriver_port + ) + } + // Prefer ipv4 address + socket_addrs.sort_by(|a, b| { + let a_val = i32::from(!a.ip().is_ipv4()); + let b_val = i32::from(!b.ip().is_ipv4()); + a_val.partial_cmp(&b_val).expect("Comparison failed") + }); + Ok(socket_addrs.remove(0)) +} + +/// Parse a given string into a Host +fn parse_hostname(webdriver_host: &str) -> Result { + let host_str = if let Ok(ip_addr) = IpAddr::from_str(webdriver_host) { + // In this case we have an IP address as the host + if ip_addr.is_ipv6() { + // Convert to quoted form + format!("[{}]", &webdriver_host) + } else { + webdriver_host.into() + } + } else { + webdriver_host.into() + }; + + Host::parse(&host_str) +} + +/// Get a list of default hostnames to allow +/// +/// This only covers domain names, not IP addresses, since IP adresses +/// are always accepted. +fn get_default_allowed_hosts(ip: IpAddr) -> Vec> { + let localhost_is_loopback = ("localhost".to_string(), 80) + .to_socket_addrs() + .map(|addr_iter| { + addr_iter + .map(|addr| addr.ip()) + .filter(|ip| ip.is_loopback()) + }) + .iter() + .len() + > 0; + if ip.is_loopback() && localhost_is_loopback { + vec![Host::parse("localhost")] + } else { + vec![] + } +} + +fn get_allowed_hosts( + host: Host, + allow_hosts: Option, +) -> Result, url::ParseError> { + allow_hosts + .map(|hosts| hosts.map(Host::parse).collect::>()) + .unwrap_or_else(|| match host { + Host::Domain(_) => { + vec![Ok(host.clone())] + } + Host::Ipv4(ip) => get_default_allowed_hosts(IpAddr::V4(ip)), + Host::Ipv6(ip) => get_default_allowed_hosts(IpAddr::V6(ip)), + }) + .into_iter() + .collect::, url::ParseError>>() +} + +fn get_allowed_origins(allow_origins: Option) -> Result, url::ParseError> { + allow_origins + .map(|origins| { + origins + .map(Url::parse) + .collect::, url::ParseError>>() + }) + .unwrap_or_else(|| Ok(vec![])) +} + +fn parse_args(cmd: &mut Command) -> ProgramResult { + let args = cmd.try_get_matches_from_mut(env::args())?; + + if args.is_present("help") { + return Ok(Operation::Help); + } else if args.is_present("version") { + return Ok(Operation::Version); + } + + let log_level = if args.is_present("log_level") { + Level::from_str(args.value_of("log_level").unwrap()).ok() + } else { + Some(match args.occurrences_of("verbosity") { + 0 => Level::Info, + 1 => Level::Debug, + _ => Level::Trace, + }) + }; + + let webdriver_host = args.value_of("webdriver_host").unwrap(); + let webdriver_port = { + let s = args.value_of("webdriver_port").unwrap(); + match u16::from_str(s) { + Ok(n) => n, + Err(e) => usage!("invalid --port: {}: {}", e, s), + } + }; + + let android_storage = args + .value_of_t::("android_storage") + .unwrap_or(AndroidStorageInput::Auto); + + let binary = args.value_of("binary").map(PathBuf::from); + + let profile_root = args.value_of("profile_root").map(PathBuf::from); + + // Try to create a temporary directory on startup to check that the directory exists and is writable + { + let tmp_dir = if let Some(ref tmp_root) = profile_root { + tempfile::tempdir_in(tmp_root) + } else { + tempfile::tempdir() + }; + if tmp_dir.is_err() { + usage!("Unable to write to temporary directory; consider --profile-root with a writeable directory") + } + } + + let marionette_host = args.value_of("marionette_host").unwrap(); + let marionette_port = match args.value_of("marionette_port") { + Some(s) => match u16::from_str(s) { + Ok(n) => Some(n), + Err(e) => usage!("invalid --marionette-port: {}", e), + }, + None => None, + }; + + // For Android the port on the device must be the same as the one on the + // host. For now default to 9222, which is the default for --remote-debugging-port. + let websocket_port = match args.value_of("websocket_port") { + Some(s) => match u16::from_str(s) { + Ok(n) => n, + Err(e) => usage!("invalid --websocket-port: {}", e), + }, + None => 9222, + }; + + let host = match parse_hostname(webdriver_host) { + Ok(name) => name, + Err(e) => usage!("invalid --host {}: {}", webdriver_host, e), + }; + + let allow_hosts = match get_allowed_hosts(host, args.values_of("allow_hosts")) { + Ok(hosts) => hosts, + Err(e) => usage!("invalid --allow-hosts {}", e), + }; + + let allow_origins = match get_allowed_origins(args.values_of("allow_origins")) { + Ok(origins) => origins, + Err(e) => usage!("invalid --allow-origins {}", e), + }; + + let address = server_address(webdriver_host, webdriver_port)?; + + let settings = MarionetteSettings { + binary, + profile_root, + connect_existing: args.is_present("connect_existing"), + host: marionette_host.into(), + port: marionette_port, + websocket_port, + allow_hosts: allow_hosts.clone(), + allow_origins: allow_origins.clone(), + jsdebugger: args.is_present("jsdebugger"), + android_storage, + }; + Ok(Operation::Server { + log_level, + log_truncate: !args.is_present("log_no_truncate"), + allow_hosts, + allow_origins, + address, + settings, + deprecated_storage_arg: args.is_present("android_storage"), + }) +} + +fn inner_main(cmd: &mut Command) -> ProgramResult<()> { + match parse_args(cmd)? { + Operation::Help => print_help(cmd), + Operation::Version => print_version(), + + Operation::Server { + log_level, + log_truncate, + address, + allow_hosts, + allow_origins, + settings, + deprecated_storage_arg, + } => { + if let Some(ref level) = log_level { + logging::init_with_level(*level, log_truncate).unwrap(); + } else { + logging::init(log_truncate).unwrap(); + } + + if deprecated_storage_arg { + warn!("--android-storage argument is deprecated and will be removed soon."); + }; + + let handler = MarionetteHandler::new(settings); + let listening = webdriver::server::start( + address, + allow_hosts, + allow_origins, + handler, + extension_routes(), + )?; + info!("Listening on {}", listening.socket); + } + } + + Ok(()) +} + +fn main() { + use std::process::exit; + + let mut cmd = make_command(); + + // use std::process:Termination when it graduates + exit(match inner_main(&mut cmd) { + Ok(_) => EXIT_SUCCESS, + + Err(e) => { + eprintln!("{}: {}", get_program_name(), e); + if !e.help_included() { + print_help(&mut cmd); + } + + e.exit_code() + } + }); +} + +fn make_command<'a>() -> Command<'a> { + Command::new(format!("geckodriver {}", build::build_info())) + .setting(AppSettings::NoAutoHelp) + .setting(AppSettings::NoAutoVersion) + .about("WebDriver implementation for Firefox") + .arg( + Arg::new("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::new("webdriver_port") + .short('p') + .long("port") + .takes_value(true) + .value_name("PORT") + .default_value("4444") + .help("Port to use for WebDriver server"), + ) + .arg( + Arg::new("binary") + .short('b') + .long("binary") + .takes_value(true) + .value_name("BINARY") + .help("Path to the Firefox binary"), + ) + .arg( + Arg::new("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::new("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::new("websocket_port") + .long("websocket-port") + .takes_value(true) + .value_name("PORT") + .conflicts_with("connect_existing") + .help("Port to use to connect to WebDriver BiDi [default: 9222]"), + ) + .arg( + Arg::new("connect_existing") + .long("connect-existing") + .requires("marionette_port") + .help("Connect to an existing Firefox instance"), + ) + .arg( + Arg::new("jsdebugger") + .long("jsdebugger") + .help("Attach browser toolbox debugger for Firefox"), + ) + .arg( + Arg::new("verbosity") + .multiple_occurrences(true) + .conflicts_with("log_level") + .short('v') + .help("Log level verbosity (-v for debug and -vv for trace level)"), + ) + .arg( + Arg::new("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::new("log_no_truncate") + .long("log-no-truncate") + .help("Disable truncation of long log lines"), + ) + .arg( + Arg::new("help") + .short('h') + .long("help") + .help("Prints this message"), + ) + .arg( + Arg::new("version") + .short('V') + .long("version") + .help("Prints version and copying information"), + ) + .arg( + Arg::new("profile_root") + .long("profile-root") + .takes_value(true) + .value_name("PROFILE_ROOT") + .help("Directory in which to create profiles. Defaults to the system temporary directory."), + ) + .arg( + Arg::new("android_storage") + .long("android-storage") + .possible_values(["auto", "app", "internal", "sdcard"]) + .value_name("ANDROID_STORAGE") + .help("Selects storage location to be used for test data (deprecated)."), + ) + .arg( + Arg::new("allow_hosts") + .long("allow-hosts") + .takes_value(true) + .multiple_values(true) + .value_name("ALLOW_HOSTS") + .help("List of hostnames to allow. By default the value of --host is allowed, and in addition if that's a well known local address, other variations on well known local addresses are allowed. If --allow-hosts is provided only exactly those hosts are allowed."), + ) + .arg( + Arg::new("allow_origins") + .long("allow-origins") + .takes_value(true) + .multiple_values(true) + .value_name("ALLOW_ORIGINS") + .help("List of request origins to allow. These must be formatted as scheme://host:port. By default any request with an origin header is rejected. If --allow-origins is provided then only exactly those origins are allowed."), + ) +} + +fn get_program_name() -> String { + env::args().next().unwrap() +} + +fn print_help(cmd: &mut Command) { + cmd.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..836382b7db --- /dev/null +++ b/testing/geckodriver/src/marionette.rs @@ -0,0 +1,1623 @@ +/* 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::browser::{Browser, LocalBrowser, RemoteBrowser}; +use crate::build; +use crate::capabilities::{FirefoxCapabilities, FirefoxOptions, ProfileType}; +use crate::command::{ + AddonInstallParameters, AddonUninstallParameters, GeckoContextParameters, + GeckoExtensionCommand, GeckoExtensionRoute, +}; +use crate::logging; +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, 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 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::{Shutdown, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::thread; +use std::time; +use url::{Host, Url}; +use webdriver::capabilities::BrowserCapabilities; +use webdriver::command::WebDriverCommand::{ + AcceptAlert, AddCookie, CloseWindow, DeleteCookie, DeleteCookies, DeleteSession, DismissAlert, + ElementClear, ElementClick, ElementSendKeys, ExecuteAsyncScript, ExecuteScript, Extension, + FindElement, FindElementElement, FindElementElements, FindElements, FindShadowRootElement, + FindShadowRootElements, FullscreenWindow, Get, GetActiveElement, GetAlertText, GetCSSValue, + GetComputedLabel, GetComputedRole, GetCookies, GetCurrentUrl, GetElementAttribute, + GetElementProperty, GetElementRect, GetElementTagName, GetElementText, GetNamedCookie, + GetPageSource, GetShadowRoot, 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, ShadowRoot, WebElement, ELEMENT_KEY, FRAME_KEY, + SHADOW_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 webdriver::{capabilities::CapabilitiesMatching, server::SessionTeardownKind}; + +#[derive(Debug, PartialEq, Deserialize)] +struct MarionetteHandshake { + #[serde(rename = "marionetteProtocol")] + protocol: u16, + #[serde(rename = "applicationType")] + application_type: String, +} + +#[derive(Default)] +pub(crate) struct MarionetteSettings { + pub(crate) binary: Option, + pub(crate) profile_root: Option, + pub(crate) connect_existing: bool, + pub(crate) host: String, + pub(crate) port: Option, + pub(crate) websocket_port: u16, + pub(crate) allow_hosts: Vec, + pub(crate) allow_origins: Vec, + + /// Brings up the Browser Toolbox when starting Firefox, + /// letting you debug internals. + pub(crate) jsdebugger: bool, + + pub(crate) android_storage: AndroidStorageInput, +} + +#[derive(Default)] +pub(crate) struct MarionetteHandler { + connection: Mutex>, + settings: MarionetteSettings, +} + +impl MarionetteHandler { + pub(crate) fn new(settings: MarionetteSettings) -> MarionetteHandler { + MarionetteHandler { + connection: Mutex::new(None), + settings, + } + } + + fn create_connection( + &self, + session_id: Option, + new_session_parameters: &NewSessionParameters, + ) -> WebDriverResult { + let mut fx_capabilities = FirefoxCapabilities::new(self.settings.binary.as_ref()); + let (capabilities, options) = { + 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.clone(), + &self.settings, + &mut capabilities, + )?; + (capabilities, options) + }; + + if let Some(l) = options.log.level { + logging::set_max_level(l); + } + + let marionette_host = self.settings.host.to_owned(); + let marionette_port = match self.settings.port { + Some(port) => port, + None => { + // If we're launching Firefox Desktop version 95 or later, and there's no port + // specified, we can pass 0 as the port and later read it back from + // the profile. + let can_use_profile: bool = options.android.is_none() + && options.profile != ProfileType::Named + && !self.settings.connect_existing + && fx_capabilities + .browser_version(&capabilities) + .map(|opt_v| { + opt_v + .map(|v| { + fx_capabilities + .compare_browser_version(&v, ">=95") + .unwrap_or(false) + }) + .unwrap_or(false) + }) + .unwrap_or(false); + if can_use_profile { + 0 + } else { + get_free_port(&marionette_host)? + } + } + }; + + let websocket_port = if options.use_websocket { + Some(self.settings.websocket_port) + } else { + None + }; + + let browser = if options.android.is_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", + )); + } + Browser::Remote(RemoteBrowser::new( + options, + marionette_port, + websocket_port, + self.settings.profile_root.as_deref(), + )?) + } else if !self.settings.connect_existing { + Browser::Local(LocalBrowser::new( + options, + marionette_port, + self.settings.jsdebugger, + self.settings.profile_root.as_deref(), + )?) + } else { + Browser::Existing(marionette_port) + }; + let session = MarionetteSession::new(session_id, capabilities); + MarionetteConnection::new(marionette_host, browser, session) + } + + fn close_connection(&mut self, wait_for_shutdown: bool) { + if let Ok(connection) = self.connection.get_mut() { + if let Some(conn) = connection.take() { + if let Err(e) = conn.close(wait_for_shutdown) { + error!("Failed to close browser connection: {}", e) + } + } + } + } +} + +impl WebDriverHandler for MarionetteHandler { + fn handle_command( + &mut self, + _: &Option, + msg: WebDriverMessage, + ) -> WebDriverResult { + // 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 + .get_mut() + .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(mut connection) => { + if connection.is_none() { + if let NewSession(ref capabilities) = msg.command { + let conn = self.create_connection(msg.session_id.clone(), capabilities)?; + *connection = Some(conn); + } else { + return Err(WebDriverError::new( + ErrorStatus::InvalidSessionId, + "Tried to run command without establishing a connection", + )); + } + } + let conn = connection.as_mut().expect("Missing connection"); + conn.send_command(&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 + }) + } + Err(_) => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to aquire Marionette connection", + )), + } + } + + fn teardown_session(&mut self, kind: SessionTeardownKind) { + let wait_for_shutdown = match kind { + SessionTeardownKind::Deleted => true, + SessionTeardownKind::NotDeleted => false, + }; + self.close_connection(wait_for_shutdown); + } +} + +impl Drop for MarionetteHandler { + fn drop(&mut self) { + self.close_connection(false); + } +} + +struct MarionetteSession { + session_id: String, + capabilities: Map, + command_id: MessageId, +} + +impl MarionetteSession { + fn new(session_id: Option, capabilities: Map) -> MarionetteSession { + let initital_id = session_id.unwrap_or_default(); + MarionetteSession { + session_id: initital_id, + capabilities, + command_id: 0, + } + } + + fn update( + &mut self, + msg: &WebDriverMessage, + 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(); + }; + 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 { + let data = try_opt!( + json_data.as_object(), + ErrorStatus::UnknownError, + "Failed to convert data to an object" + ); + + let element = data.get(ELEMENT_KEY); + let frame = data.get(FRAME_KEY); + let window = data.get(WINDOW_KEY); + + let value = try_opt!( + 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)) + } + + /// Converts a Marionette JSON response into a `ShadowRoot`. + fn to_shadow_root(&self, json_data: &Value) -> WebDriverResult { + let data = try_opt!( + json_data.as_object(), + ErrorStatus::UnknownError, + "Failed to convert data to an object" + ); + + let shadow_root = data.get(SHADOW_KEY); + + let value = try_opt!( + shadow_root, + ErrorStatus::UnknownError, + "Failed to extract shadow root from Marionette response" + ); + let id = try_opt!( + value.as_str(), + ErrorStatus::UnknownError, + "Failed to convert shadow root reference value to string" + ) + .to_string(); + Ok(ShadowRoot(id)) + } + + fn next_command_id(&mut self) -> MessageId { + self.command_id += 1; + self.command_id + } + + fn response( + &mut self, + msg: &WebDriverMessage, + resp: MarionetteResponse, + ) -> WebDriverResult { + 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(_) + | GetComputedLabel(_) + | GetComputedRole(_) + | 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" + ), + }; + let page_load = try_opt!( + try_opt!( + resp.result.get("pageLoad"), + 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::, _>>()?; + 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 = serde_json::from_value(resp.result)?; + WebDriverResponse::Cookies(CookiesResponse(cookies)) + } + GetNamedCookie(ref name) => { + let mut cookies: Vec = 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(_, _) | FindShadowRootElement(_, _) => { + 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(_, _) | FindShadowRootElements(_, _) => { + 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::, _>>()?; + + // TODO(Henrik): How to remove unwrap? + WebDriverResponse::Generic(ValueResponse(Value::Array( + elements + .iter() + .map(|x| serde_json::to_value(x).unwrap()) + .collect(), + ))) + } + GetShadowRoot(_) => { + let shadow_root = self.to_shadow_root(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(shadow_root)?)) + } + 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), + )) + } + 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, + browser: &Browser, +) -> WebDriverResult> { + 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 => match browser { + Browser::Local(_) | Browser::Remote(_) => Some(Command::Marionette( + marionette_rs::marionette::Command::DeleteSession { + flags: vec![AppStatus::eForceQuit], + }, + )), + Browser::Existing(_) => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteSession, + )), + }, + DismissAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::DismissAlert)), + ElementClear(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClear { + id: e.clone().to_string(), + }, + )), + ElementClick(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClick { + id: e.clone().to_string(), + }, + )), + 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, + }, + )) + } + 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, + }, + )) + } + 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, + }, + )) + } + FindShadowRootElement(ref s, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindShadowRootElement { + shadow_root: s.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + FindShadowRootElements(ref s, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindShadowRootElements { + shadow_root: s.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + 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)), + GetComputedLabel(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetComputedLabel { + id: e.clone().to_string(), + }, + )), + GetComputedRole(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetComputedRole { + id: e.clone().to_string(), + }, + )), + 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 e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementRect { + id: e.clone().to_string(), + }, + )), + GetElementTagName(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementTagName { + id: e.clone().to_string(), + }, + )), + GetElementText(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementText { + id: e.clone().to_string(), + }, + )), + GetPageSource => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetPageSource, + )), + GetShadowRoot(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetShadowRoot { + id: e.clone().to_string(), + }, + )), + 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 e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::IsDisplayed { + id: e.clone().to_string(), + }, + )), + IsEnabled(ref e) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsEnabled { + id: e.clone().to_string(), + })), + IsSelected(ref e) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsSelected { + id: e.clone().to_string(), + })), + 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(TakeFullScreenshot) => { + let screenshot = ScreenshotOptions { + id: None, + highlights: vec![], + full: true, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeFullScreenshot(screenshot), + )) + } + _ => None, + }) +} + +#[derive(Debug, PartialEq)] +struct MarionetteCommand { + id: MessageId, + name: String, + params: Map, +} + +impl Serialize for MarionetteCommand { + fn serialize(&self, serializer: S) -> Result + 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) -> MarionetteCommand { + MarionetteCommand { id, name, params } + } + + fn encode_msg(msg: T) -> WebDriverResult + where + T: serde::Serialize, + { + let data = serde_json::to_string(&msg)?; + + Ok(format!("{}:{}", data.len(), data)) + } + + fn from_webdriver_message( + id: MessageId, + capabilities: &Map, + browser: &Browser, + msg: &WebDriverMessage, + ) -> WebDriverResult { + use self::GeckoExtensionCommand::*; + + if let Some(cmd) = try_convert_to_marionette_message(msg, browser)? { + 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 mut data = Map::new(); + for (k, v) in capabilities.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)] +struct MarionetteResponse { + id: MessageId, + error: Option, + result: Value, +} + +impl<'de> Deserialize<'de> for MarionetteResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct ResponseWrapper { + msg_type: u64, + id: MessageId, + error: Option, + 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 { + 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)] +struct MarionetteError { + #[serde(rename = "error")] + code: String, + message: String, + stacktrace: Option, +} + +impl From for WebDriverError { + fn from(error: MarionetteError) -> WebDriverError { + let status = ErrorStatus::from(error.code); + let message = error.message; + + if let Some(stack) = error.stacktrace { + WebDriverError::new_with_stack(status, message, stack) + } else { + WebDriverError::new(status, message) + } + } +} + +fn get_free_port(host: &str) -> IoResult { + TcpListener::bind((host, 0)) + .and_then(|stream| stream.local_addr()) + .map(|x| x.port()) +} + +struct MarionetteConnection { + browser: Browser, + session: MarionetteSession, + stream: TcpStream, +} + +impl MarionetteConnection { + fn new( + host: String, + mut browser: Browser, + session: MarionetteSession, + ) -> WebDriverResult { + let stream = match MarionetteConnection::connect(&host, &mut browser) { + Ok(stream) => stream, + Err(e) => { + if let Err(e) = browser.close(true) { + error!("Failed to stop browser: {:?}", e); + } + return Err(e); + } + }; + Ok(MarionetteConnection { + browser, + session, + stream, + }) + } + + fn connect(host: &str, browser: &mut 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(), + host, + ); + + loop { + // immediately abort connection attempts if process disappears + if let Browser::Local(browser) = browser { + if let Some(status) = browser.check_status() { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!("Process unexpectedly closed with status {}", status), + )); + } + } + + let last_err; + + if let Some(port) = browser.marionette_port()? { + match MarionetteConnection::try_connect(host, port) { + Ok(stream) => { + debug!("Connection to Marionette established on {}:{}.", host, port); + browser.update_marionette_port(port); + return Ok(stream); + } + Err(e) => { + let err_str = e.to_string(); + last_err = Some(err_str); + } + } + } else { + last_err = Some("Failed to read marionette port".into()); + } + if now.elapsed() < timeout { + trace!("Retrying in {:?}", poll_interval); + thread::sleep(poll_interval); + } else { + return Err(WebDriverError::new( + ErrorStatus::Timeout, + last_err.unwrap_or_else(|| "Unknown error".into()), + )); + } + } + } + + fn try_connect(host: &str, port: u16) -> WebDriverResult { + let mut stream = TcpStream::connect((host, port))?; + MarionetteConnection::handshake(&mut stream)?; + Ok(stream) + } + + fn handshake(stream: &mut TcpStream) -> WebDriverResult { + 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. Don't + // make it shorter as 1000ms to not fail on slow connections. + stream + .set_read_timeout(Some(time::Duration::from_millis(1000))) + .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::(&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) + } + + fn close(self, wait_for_shutdown: bool) -> WebDriverResult<()> { + self.stream.shutdown(Shutdown::Both)?; + self.browser.close(wait_for_shutdown)?; + Ok(()) + } + + fn send_command( + &mut self, + msg: &WebDriverMessage, + ) -> WebDriverResult { + let id = self.session.next_command_id(); + let enc_cmd = MarionetteCommand::from_webdriver_message( + id, + &self.session.capabilities, + &self.browser, + 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 { + if self.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); + } + + match MarionetteConnection::read_resp(&mut self.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 { + let mut bytes = 0usize; + + loop { + let buf = &mut [0u8]; + 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], + _ => panic!("Expected one byte got more"), + } as char; + match byte { + '0'..='9' => { + bytes *= 10; + bytes += byte as usize - '0' as usize; + } + ':' => break, + _ => {} + } + } + + let buf = &mut [0u8; 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 { + fn to_marionette(&self) -> WebDriverResult; +} + +impl ToMarionette> for AddonInstallParameters { + fn to_marionette(&self) -> WebDriverResult> { + 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> for AddonUninstallParameters { + fn to_marionette(&self) -> WebDriverResult> { + let mut data = Map::new(); + data.insert("id".to_string(), Value::String(self.id.clone())); + Ok(data) + } +} + +impl ToMarionette> for GeckoContextParameters { + fn to_marionette(&self) -> WebDriverResult> { + let mut data = Map::new(); + data.insert( + "value".to_owned(), + serde_json::to_value(self.context.clone())?, + ); + Ok(data) + } +} + +impl ToMarionette for PrintParameters { + fn to_marionette(&self) -> WebDriverResult { + 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 for PrintOrientation { + fn to_marionette(&self) -> WebDriverResult { + Ok(match self { + PrintOrientation::Landscape => MarionettePrintOrientation::Landscape, + PrintOrientation::Portrait => MarionettePrintOrientation::Portrait, + }) + } +} + +impl ToMarionette for PrintPage { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionettePrintPage { + width: self.width, + height: self.height, + }) + } +} + +impl ToMarionette for PrintMargins { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionettePrintMargins { + top: self.top, + bottom: self.bottom, + left: self.left, + right: self.right, + }) + } +} + +impl ToMarionette> for ActionsParameters { + fn to_marionette(&self) -> WebDriverResult> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette for AddCookieParameters { + fn to_marionette(&self) -> WebDriverResult { + 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 for Date { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteDate(self.0)) + } +} + +impl ToMarionette> for GetNamedCookieParameters { + fn to_marionette(&self) -> WebDriverResult> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette for GetParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteUrl { + url: self.url.clone(), + }) + } +} + +impl ToMarionette for JavascriptCommandParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteScript { + script: self.script.clone(), + args: self.args.clone(), + }) + } +} + +impl ToMarionette for LocatorParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteLocator { + using: self.using.to_marionette()?, + value: self.value.clone(), + }) + } +} + +impl ToMarionette for LocatorStrategy { + fn to_marionette(&self) -> WebDriverResult { + 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 for NewWindowParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteNewWindow { + type_hint: self.type_hint.clone(), + }) + } +} + +impl ToMarionette for SendKeysParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteKeys { + text: self.text.clone(), + value: self + .text + .chars() + .map(|x| x.to_string()) + .collect::>(), + }) + } +} + +impl ToMarionette for SwitchToFrameParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(match &self.id { + Some(x) => match x { + FrameId::Short(n) => MarionetteFrame::Index(*n), + FrameId::Element(el) => MarionetteFrame::Element(el.0.clone()), + }, + None => MarionetteFrame::Parent, + }) + } +} + +impl ToMarionette for SwitchToWindowParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(Window { + handle: self.handle.clone(), + }) + } +} + +impl ToMarionette for TimeoutsParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteTimeouts { + implicit: self.implicit, + page_load: self.page_load, + script: self.script, + }) + } +} + +impl ToMarionette for WebElement { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteWebElement { + element: self.to_string(), + }) + } +} + +impl ToMarionette for WindowRectParameters { + fn to_marionette(&self) -> WebDriverResult { + Ok(MarionetteWindowRect { + x: self.x, + y: self.y, + width: self.width, + height: self.height, + }) + } +} diff --git a/testing/geckodriver/src/prefs.rs b/testing/geckodriver/src/prefs.rs new file mode 100644 index 0000000000..4407cbccaa --- /dev/null +++ b/testing/geckodriver/src/prefs.rs @@ -0,0 +1,150 @@ +/* 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! +// +// Please refer to INSTRUCTIONS TO ADD A NEW PREFERENCE in +// remote/shared/RecommendedPreferences.sys.mjs +// +// 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)), + + // 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)), + + // 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)), + + // Disable page translations, causing timeouts for wdspec tests in early + // beta. See Bug 1836093. + ("browser.translations.enable", 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)), + + // 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 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")), + + // Don't do network connections for mitm priming + ("security.certerrors.mitm.priming.enabled", Pref::new(false)), + + // Ensure remote settings do not hit the network + ("services.settings.server", Pref::new("data:,#remote-settings-dummy/v1")), + + // Disable first run pages + ("startup.homepage_welcome_url", Pref::new("about:blank")), + ("startup.homepage_welcome_url.additional", Pref::new("")), + + // asrouter expects a plain object or null + ("browser.newtabpage.activity-stream.asrouter.providers.cfr", Pref::new("null")), + // TODO: Remove once minimum supported Firefox release is 93. + ("browser.newtabpage.activity-stream.asrouter.providers.cfr-fxa", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.snippets", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.message-groups", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", Pref::new("null")), + ("browser.newtabpage.activity-stream.feeds.system.topstories", Pref::new(false)), + ("browser.newtabpage.activity-stream.feeds.snippets", Pref::new(false)), + ("browser.newtabpage.activity-stream.tippyTop.service.endpoint", Pref::new("")), + ("browser.newtabpage.activity-stream.discoverystream.config", Pref::new("[]")), + + // For Activity Stream firstrun page, use an empty string to avoid fetching. + ("browser.newtabpage.activity-stream.fxaccounts.endpoint", Pref::new("")), + + // Prevent starting into safe mode after application crashes + ("toolkit.startup.max_resumed_crashes", Pref::new(-1)), + + // Disable webapp updates. + ("browser.webapps.checkForUpdates", Pref::new(0)), + ]; +} 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(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::(json).unwrap()); +} diff --git a/testing/geckodriver/src/tests/profile.zip b/testing/geckodriver/src/tests/profile.zip new file mode 100644 index 0000000000..286b118183 Binary files /dev/null and b/testing/geckodriver/src/tests/profile.zip differ -- cgit v1.2.3