summaryrefslogtreecommitdiffstats
path: root/testing/geckodriver/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /testing/geckodriver/src
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--testing/geckodriver/src/android.rs533
-rw-r--r--testing/geckodriver/src/browser.rs556
-rw-r--r--testing/geckodriver/src/build.rs47
-rw-r--r--testing/geckodriver/src/capabilities.rs1417
-rw-r--r--testing/geckodriver/src/command.rs343
-rw-r--r--testing/geckodriver/src/logging.rs403
-rw-r--r--testing/geckodriver/src/main.rs549
-rw-r--r--testing/geckodriver/src/marionette.rs1623
-rw-r--r--testing/geckodriver/src/prefs.rs150
-rw-r--r--testing/geckodriver/src/test.rs12
-rw-r--r--testing/geckodriver/src/tests/profile.zipbin0 -> 444 bytes
11 files changed, 5633 insertions, 0 deletions
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<T> = std::result::Result<T, AndroidError>;
+
+#[derive(Debug)]
+pub enum AndroidError {
+ ActivityNotFound(String),
+ Device(mozdevice::DeviceError),
+ IO(io::Error),
+ PackageNotFound(String),
+ Serde(serde_yaml::Error),
+}
+
+impl fmt::Display for AndroidError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ AndroidError::ActivityNotFound(ref package) => {
+ write!(f, "Activity for package '{}' not found", package)
+ }
+ AndroidError::Device(ref message) => message.fmt(f),
+ AndroidError::IO(ref message) => message.fmt(f),
+ AndroidError::PackageNotFound(ref package) => {
+ write!(f, "Package '{}' not found", package)
+ }
+ AndroidError::Serde(ref message) => message.fmt(f),
+ }
+ }
+}
+
+impl From<io::Error> for AndroidError {
+ fn from(value: io::Error) -> AndroidError {
+ AndroidError::IO(value)
+ }
+}
+
+impl From<mozdevice::DeviceError> for AndroidError {
+ fn from(value: mozdevice::DeviceError) -> AndroidError {
+ AndroidError::Device(value)
+ }
+}
+
+impl From<serde_yaml::Error> for AndroidError {
+ fn from(value: serde_yaml::Error) -> AndroidError {
+ AndroidError::Serde(value)
+ }
+}
+
+impl From<AndroidError> for WebDriverError {
+ fn from(value: AndroidError) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::UnknownError, value.to_string())
+ }
+}
+
+/// A remote Gecko instance.
+///
+/// Host refers to the device running `geckodriver`. Target refers to the
+/// Android device running Gecko in a GeckoView-based vehicle.
+#[derive(Debug)]
+pub struct AndroidProcess {
+ pub device: Device,
+ pub package: String,
+ pub activity: String,
+}
+
+impl AndroidProcess {
+ pub fn new(
+ device: Device,
+ package: String,
+ activity: String,
+ ) -> mozdevice::Result<AndroidProcess> {
+ Ok(AndroidProcess {
+ device,
+ package,
+ activity,
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct AndroidHandler {
+ pub config: 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<u16>,
+}
+
+impl Drop for AndroidHandler {
+ fn drop(&mut self) {
+ // Try to clean up various settings
+ let clear_command = format!("am clear-debug-app {}", self.process.package);
+ match self
+ .process
+ .device
+ .execute_host_shell_command(&clear_command)
+ {
+ Ok(_) => debug!("Disabled reading from configuration file"),
+ Err(e) => error!("Failed disabling from configuration file: {}", e),
+ }
+
+ match self.process.device.remove(&self.config) {
+ Ok(_) => debug!("Deleted GeckoView configuration file"),
+ Err(e) => error!("Failed deleting GeckoView configuration file: {}", e),
+ }
+
+ match self.process.device.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<u16>,
+ ) -> Result<AndroidHandler> {
+ // We need to push profile.pathbuf to a safe space on the device.
+ // Make it per-Android package to avoid clashes and confusion.
+ // This naming scheme follows GeckoView's configuration file naming scheme,
+ // see bug 1533385.
+
+ let host = Host {
+ host: None,
+ port: None,
+ read_timeout: Some(time::Duration::from_millis(5000)),
+ write_timeout: Some(time::Duration::from_millis(5000)),
+ };
+
+ let mut device = host.device_or_default(options.device_serial.as_ref(), options.storage)?;
+
+ // Set up port 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::<Vec<&str>>();
+ if activities.is_empty() {
+ return Err(AndroidError::ActivityNotFound(options.package.clone()));
+ }
+
+ activities[0].to_owned()
+ }
+ };
+
+ let process = AndroidProcess::new(device, options.package.clone(), activity)?;
+
+ Ok(AndroidHandler {
+ config,
+ process,
+ profile,
+ test_root,
+ marionette_host_port,
+ marionette_target_port: MARIONETTE_TARGET_PORT,
+ options: options.clone(),
+ websocket_port,
+ })
+ }
+
+ pub fn generate_config_file<I, K, V>(
+ &self,
+ args: Option<Vec<String>>,
+ envs: I,
+ ) -> Result<String>
+ where
+ I: IntoIterator<Item = (K, V)>,
+ K: ToString,
+ V: ToString,
+ {
+ // To configure GeckoView, we use the automation techniques documented at
+ // https://mozilla.github.io/geckoview/consumer/docs/automation.
+ #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
+ pub struct Config {
+ pub env: Mapping,
+ pub args: Vec<String>,
+ }
+
+ 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<String> = vec![CONFIG_FILE_HEADING.to_owned()];
+ contents.push(serde_yaml::to_string(&config)?);
+
+ Ok(contents.concat())
+ }
+
+ pub fn prepare<I, K, V>(
+ &self,
+ profile: &Profile,
+ args: Option<Vec<String>>,
+ env: I,
+ ) -> Result<()>
+ where
+ I: IntoIterator<Item = (K, V)>,
+ K: ToString,
+ V: ToString,
+ {
+ self.process.device.clear_app_data(&self.process.package)?;
+
+ // These permissions, at least, are required to read profiles in /mnt/sdcard.
+ for perm in &["READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] {
+ self.process.device.execute_host_shell_command(&format!(
+ "pm grant {} android.permission.{}",
+ &self.process.package, perm
+ ))?;
+ }
+
+ // Make sure to create the test root.
+ self.process.device.create_dir(&self.test_root)?;
+ self.process.device.chmod(&self.test_root, "777", true)?;
+
+ // Replace the profile
+ self.process.device.remove(&self.profile)?;
+ self.process
+ .device
+ .push_dir(&profile.path, &self.profile, 0o777)?;
+
+ let contents = self.generate_config_file(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<Option<u16>> {
+ 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<PrefsBackup>,
+ process: FirefoxProcess,
+ profile_path: Option<PathBuf>,
+}
+
+impl LocalBrowser {
+ pub(crate) fn new(
+ options: FirefoxOptions,
+ marionette_port: u16,
+ jsdebugger: bool,
+ profile_root: Option<&Path>,
+ ) -> WebDriverResult<LocalBrowser> {
+ 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<Option<u16>> {
+ 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<String> {
+ 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<u16> {
+ 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::<u16>().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<PrefsBackup>,
+}
+
+impl RemoteBrowser {
+ pub(crate) fn new(
+ options: FirefoxOptions,
+ marionette_port: u16,
+ websocket_port: Option<u16>,
+ profile_root: Option<&Path>,
+ ) -> WebDriverResult<RemoteBrowser> {
+ 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<Option<u16>> {
+ 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<Option<PrefsBackup>> {
+ 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<PrefsBackup> {
+ 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<String, Value> = 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<BuildInfo> 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<mozversion::Error> for VersionError {
+ fn from(err: mozversion::Error) -> VersionError {
+ VersionError::VersionError(err)
+ }
+}
+
+impl Display for VersionError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ VersionError::VersionError(ref x) => x.fmt(f),
+ VersionError::MissingBinary => "No binary provided".fmt(f),
+ }
+ }
+}
+
+impl From<VersionError> for WebDriverError {
+ fn from(err: VersionError) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::SessionNotCreated, err.to_string())
+ }
+}
+
+/// Provides matching of `moz:firefoxOptions` and resolutionnized of which Firefox
+/// binary to use.
+///
+/// `FirefoxCapabilities` is constructed with the fallback binary, should
+/// `moz:firefoxOptions` not contain a binary entry. This may either be the
+/// system Firefox installation or an override, for example given to the
+/// `--binary` flag of geckodriver.
+pub struct FirefoxCapabilities<'a> {
+ pub chosen_binary: Option<PathBuf>,
+ fallback_binary: Option<&'a PathBuf>,
+ version_cache: BTreeMap<PathBuf, Result<Version, VersionError>>,
+}
+
+impl<'a> FirefoxCapabilities<'a> {
+ pub fn new(fallback_binary: Option<&'a PathBuf>) -> FirefoxCapabilities<'a> {
+ FirefoxCapabilities {
+ chosen_binary: None,
+ fallback_binary,
+ version_cache: BTreeMap::new(),
+ }
+ }
+
+ fn set_binary(&mut self, capabilities: &Map<String, Value>) {
+ self.chosen_binary = capabilities
+ .get("moz:firefoxOptions")
+ .and_then(|x| x.get("binary"))
+ .and_then(|x| x.as_str())
+ .map(PathBuf::from)
+ .or_else(|| self.fallback_binary.cloned())
+ .or_else(firefox_default_path);
+ }
+
+ fn version(&mut self, binary: Option<&Path>) -> Result<Version, VersionError> {
+ if let Some(binary) = binary {
+ if let Some(cache_value) = self.version_cache.get(binary) {
+ return cache_value.clone();
+ }
+ let rv = self
+ .version_from_ini(binary)
+ .or_else(|_| self.version_from_binary(binary));
+ if let Ok(ref version) = rv {
+ debug!("Found version {}", version);
+ } else {
+ debug!("Failed to get binary version");
+ }
+ self.version_cache.insert(binary.to_path_buf(), rv.clone());
+ rv
+ } else {
+ Err(VersionError::MissingBinary)
+ }
+ }
+
+ fn version_from_ini(&self, binary: &Path) -> Result<Version, VersionError> {
+ debug!("Trying to read firefox version from ini files");
+ let version = firefox_version(binary)?;
+ if let Some(version_string) = version.version_string {
+ Version::from_str(&version_string).map_err(|err| err.into())
+ } else {
+ Err(VersionError::VersionError(
+ mozversion::Error::MetadataError("Missing version string".into()),
+ ))
+ }
+ }
+
+ fn version_from_binary(&self, binary: &Path) -> Result<Version, VersionError> {
+ debug!("Trying to read firefox version from binary");
+ Ok(firefox_binary_version(binary)?)
+ }
+}
+
+impl<'a> BrowserCapabilities for FirefoxCapabilities<'a> {
+ fn init(&mut self, capabilities: &Capabilities) {
+ self.set_binary(capabilities);
+ }
+
+ fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> {
+ Ok(Some("firefox".into()))
+ }
+
+ fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> {
+ let binary = self.chosen_binary.clone();
+ self.version(binary.as_ref().map(|x| x.as_ref()))
+ .map_err(|err| err.into())
+ .map(|x| Some(x.to_string()))
+ }
+
+ fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> {
+ Ok(if cfg!(target_os = "windows") {
+ Some("windows".into())
+ } else if cfg!(target_os = "macos") {
+ Some("mac".into())
+ } else if cfg!(target_os = "linux") {
+ Some("linux".into())
+ } else {
+ None
+ })
+ }
+
+ fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool> {
+ Ok(true)
+ }
+
+ fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult<bool> {
+ Ok(true)
+ }
+
+ fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool> {
+ Ok(true)
+ }
+
+ fn compare_browser_version(
+ &mut self,
+ version: &str,
+ comparison: &str,
+ ) -> WebDriverResult<bool> {
+ Version::from_str(version)
+ .map_err(VersionError::from)?
+ .matches(comparison)
+ .map_err(|err| VersionError::from(err).into())
+ }
+
+ fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool> {
+ Ok(true)
+ }
+
+ fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult<bool> {
+ Ok(true)
+ }
+
+ fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()> {
+ if !name.starts_with("moz:") {
+ return Ok(());
+ }
+ match name {
+ "moz:firefoxOptions" => {
+ let data = try_opt!(
+ value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "moz:firefoxOptions is not an object"
+ );
+ for (key, value) in data.iter() {
+ match &**key {
+ "androidActivity"
+ | "androidDeviceSerial"
+ | "androidPackage"
+ | "profile" => {
+ if !value.is_string() {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a string", &**key),
+ ));
+ }
+ }
+ "androidIntentArguments" | "args" => {
+ if !try_opt!(
+ value.as_array(),
+ ErrorStatus::InvalidArgument,
+ format!("{} is not an array", &**key)
+ )
+ .iter()
+ .all(|value| value.is_string())
+ {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} entry is not a string", &**key),
+ ));
+ }
+ }
+ "binary" => {
+ if let Some(binary) = value.as_str() {
+ if !data.contains_key("androidPackage")
+ && self.version(Some(Path::new(binary))).is_err()
+ {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a Firefox executable", &**key),
+ ));
+ }
+ } else {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a string", &**key),
+ ));
+ }
+ }
+ "env" => {
+ let env_data = try_opt!(
+ value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "env value is not an object"
+ );
+ if !env_data.values().all(Value::is_string) {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Environment values were not all strings",
+ ));
+ }
+ }
+ "log" => {
+ let log_data = try_opt!(
+ value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "log value is not an object"
+ );
+ for (log_key, log_value) in log_data.iter() {
+ match &**log_key {
+ "level" => {
+ let level = try_opt!(
+ log_value.as_str(),
+ ErrorStatus::InvalidArgument,
+ "log level is not a string"
+ );
+ if Level::from_str(level).is_err() {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Not a valid log level: {}", level),
+ ));
+ }
+ }
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid log field {}", x),
+ ))
+ }
+ }
+ }
+ }
+ "prefs" => {
+ let prefs_data = try_opt!(
+ value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "prefs value is not an object"
+ );
+ let is_pref_value_type = |x: &Value| {
+ x.is_string() || x.is_i64() || x.is_u64() || x.is_boolean()
+ };
+ if !prefs_data.values().all(is_pref_value_type) {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Preference values not all string or integer or boolean",
+ ));
+ }
+ }
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid moz:firefoxOptions field {}", x),
+ ))
+ }
+ }
+ }
+ }
+ "moz:useNonSpecCompliantPointerOrigin" => {
+ 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<bool> {
+ Ok(true)
+ }
+}
+
+/// Android-specific options in the `moz:firefoxOptions` struct.
+/// These map to "androidCamelCase", following [chromedriver's Android-specific
+/// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android).
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct AndroidOptions {
+ pub activity: Option<String>,
+ pub device_serial: Option<String>,
+ pub intent_arguments: Option<Vec<String>>,
+ pub package: String,
+ pub storage: AndroidStorageInput,
+}
+
+impl AndroidOptions {
+ pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions {
+ AndroidOptions {
+ package,
+ storage,
+ ..Default::default()
+ }
+ }
+}
+
+#[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<PathBuf>,
+ pub profile: ProfileType,
+ pub args: Option<Vec<String>>,
+ pub env: Option<Vec<(String, String)>>,
+ pub log: LogOptions,
+ pub prefs: Vec<(String, Pref)>,
+ pub android: Option<AndroidOptions>,
+ pub use_websocket: bool,
+}
+
+impl FirefoxOptions {
+ pub fn new() -> FirefoxOptions {
+ Default::default()
+ }
+
+ pub(crate) fn from_capabilities(
+ binary_path: Option<PathBuf>,
+ settings: &MarionetteSettings,
+ matched: &mut Capabilities,
+ ) -> WebDriverResult<FirefoxOptions> {
+ let mut rv = FirefoxOptions::new();
+ rv.binary = binary_path;
+
+ if let Some(json) = matched.remove("moz:firefoxOptions") {
+ let options = json.as_object().ok_or_else(|| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "'moz:firefoxOptions' \
+ capability is not an object",
+ )
+ })?;
+
+ 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::<Vec<_>>().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 <name>`). \
+ Support for named profiles will be removed in a future geckodriver release. \
+ Please instead use the `--profile <path>` 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::<Vec<String>>()
+ .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::<Vec<String>>()
+ .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<Option<Profile>> {
+ if let Some(profile_json) = options.get("profile") {
+ let profile_base64 = profile_json.as_str().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Profile is not a string")
+ })?;
+ let profile_zip = &*BASE64_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<Option<Vec<String>>> {
+ if let Some(args_json) = options.get("args") {
+ let args_array = args_json.as_array().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Arguments were not an array")
+ })?;
+ let args = args_array
+ .iter()
+ .map(|x| x.as_str().map(|x| x.to_owned()))
+ .collect::<Option<Vec<String>>>()
+ .ok_or_else(|| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Arguments entries were not all strings",
+ )
+ })?;
+
+ Ok(Some(args))
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub fn load_env(options: &Capabilities) -> WebDriverResult<Option<Vec<(String, String)>>> {
+ if let Some(env_data) = options.get("env") {
+ let env = env_data.as_object().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Env was not an object")
+ })?;
+ let mut rv = Vec::with_capacity(env.len());
+ for (key, value) in env.iter() {
+ rv.push((
+ key.clone(),
+ value
+ .as_str()
+ .ok_or_else(|| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Env value is not a string",
+ )
+ })?
+ .to_string(),
+ ));
+ }
+ Ok(Some(rv))
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> {
+ if let Some(json) = options.get("log") {
+ let log = json.as_object().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Log section is not an object")
+ })?;
+
+ let level = match log.get("level") {
+ Some(json) => {
+ let s = json.as_str().ok_or_else(|| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Log level is not a string",
+ )
+ })?;
+ Some(Level::from_str(s).ok().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Log level is unknown")
+ })?)
+ }
+ None => None,
+ };
+
+ Ok(LogOptions { level })
+ } else {
+ Ok(Default::default())
+ }
+ }
+
+ pub fn load_prefs(options: &Capabilities) -> WebDriverResult<Vec<(String, Pref)>> {
+ if let Some(prefs_data) = options.get("prefs") {
+ let prefs = prefs_data.as_object().ok_or_else(|| {
+ WebDriverError::new(ErrorStatus::InvalidArgument, "Prefs were not an object")
+ })?;
+ let mut rv = Vec::with_capacity(prefs.len());
+ for (key, value) in prefs.iter() {
+ rv.push((key.clone(), pref_from_json(value)?));
+ }
+ Ok(rv)
+ } else {
+ Ok(vec![])
+ }
+ }
+
+ pub fn load_android(
+ storage: AndroidStorageInput,
+ options: &Capabilities,
+ ) -> WebDriverResult<Option<AndroidOptions>> {
+ if let Some(package_json) = options.get("androidPackage") {
+ let package = package_json
+ .as_str()
+ .ok_or_else(|| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "androidPackage is not a string",
+ )
+ })?
+ .to_owned();
+
+ // https://developer.android.com/studio/build/application-id
+ let package_regexp =
+ Regex::new(r#"^([a-zA-Z][a-zA-Z0-9_]*\.){1,}([a-zA-Z][a-zA-Z0-9_]*)$"#).unwrap();
+ if !package_regexp.is_match(package.as_bytes()) {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Not a valid androidPackage name",
+ ));
+ }
+
+ let mut android = AndroidOptions::new(package.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::<Option<Vec<String>>>()
+ .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<Pref> {
+ match *value {
+ Value::String(ref x) => Ok(Pref::new(x.clone())),
+ Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())),
+ Value::Bool(x) => Ok(Pref::new(x)),
+ _ => Err(WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Could not convert pref value to string, boolean, or integer",
+ )),
+ }
+}
+
+fn unzip_buffer(buf: &[u8], dest_dir: &Path) -> WebDriverResult<()> {
+ let reader = Cursor::new(buf);
+ let mut zip = zip::ZipArchive::new(reader)
+ .map_err(|_| WebDriverError::new(ErrorStatus::UnknownError, "Failed to unzip profile"))?;
+
+ for i in 0..zip.len() {
+ let mut file = zip.by_index(i).map_err(|_| {
+ WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Processing profile zip file failed",
+ )
+ })?;
+ let unzip_path = {
+ let name = file.name();
+ let is_dir = name.ends_with('/');
+ let rel_path = Path::new(name);
+ let dest_path = dest_dir.join(rel_path);
+
+ {
+ let create_dir = if is_dir {
+ Some(dest_path.as_path())
+ } else {
+ dest_path.parent()
+ };
+ if let Some(dir) = create_dir {
+ if !dir.exists() {
+ debug!("Creating profile directory tree {}", dir.to_string_lossy());
+ fs::create_dir_all(dir)?;
+ }
+ }
+ }
+
+ if is_dir {
+ None
+ } else {
+ Some(dest_path)
+ }
+ };
+
+ if let Some(unzip_path) = unzip_path {
+ debug!("Extracting profile to {}", unzip_path.to_string_lossy());
+ let dest = fs::File::create(unzip_path)?;
+ if file.size() > 0 {
+ let mut writer = BufWriter::new(dest);
+ io::copy(&mut file, &mut writer)?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ extern crate mozprofile;
+
+ use self::mozprofile::preferences::Pref;
+ use super::*;
+ use 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<MarionetteSettings>,
+ ) -> WebDriverResult<FirefoxOptions> {
+ 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<String, Value> = Map::new();
+ env.insert("TEST_KEY_A".into(), Value::String("test_value_a".into()));
+ env.insert("TEST_KEY_B".into(), Value::String("test_value_b".into()));
+
+ let mut firefox_opts = Capabilities::new();
+ firefox_opts.insert("env".into(), env.into());
+
+ let mut opts = make_options(firefox_opts, 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<String, Value> = Map::new();
+ env.insert("TEST_KEY".into(), Value::Number(1.into()));
+
+ let mut firefox_opts = Capabilities::new();
+ firefox_opts.insert("env".into(), env.into());
+
+ make_options(firefox_opts, 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<WebDriverCommand<GeckoExtensionCommand>> {
+ use self::GeckoExtensionRoute::*;
+
+ let command = match *self {
+ GetContext => GeckoExtensionCommand::GetContext,
+ SetContext => {
+ GeckoExtensionCommand::SetContext(serde_json::from_value(body_data.clone())?)
+ }
+ InstallAddon => {
+ GeckoExtensionCommand::InstallAddon(serde_json::from_value(body_data.clone())?)
+ }
+ UninstallAddon => {
+ GeckoExtensionCommand::UninstallAddon(serde_json::from_value(body_data.clone())?)
+ }
+ TakeFullScreenshot => GeckoExtensionCommand::TakeFullScreenshot,
+ };
+
+ Ok(WebDriverCommand::Extension(command))
+ }
+}
+
+#[derive(Clone)]
+pub enum GeckoExtensionCommand {
+ GetContext,
+ SetContext(GeckoContextParameters),
+ InstallAddon(AddonInstallParameters),
+ UninstallAddon(AddonUninstallParameters),
+ TakeFullScreenshot,
+}
+
+impl WebDriverExtensionCommand for GeckoExtensionCommand {
+ fn parameters_json(&self) -> Option<Value> {
+ use self::GeckoExtensionCommand::*;
+ match self {
+ GetContext => None,
+ InstallAddon(x) => Some(serde_json::to_value(x).unwrap()),
+ SetContext(x) => Some(serde_json::to_value(x).unwrap()),
+ UninstallAddon(x) => Some(serde_json::to_value(x).unwrap()),
+ TakeFullScreenshot => None,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+pub struct AddonInstallParameters {
+ pub path: String,
+ pub temporary: Option<bool>,
+}
+
+impl<'de> Deserialize<'de> for AddonInstallParameters {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Debug, Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Base64 {
+ addon: String,
+ temporary: Option<bool>,
+ }
+
+ #[derive(Debug, Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Path {
+ path: String,
+ temporary: Option<bool>,
+ }
+
+ #[derive(Debug, Deserialize)]
+ #[serde(untagged)]
+ enum Helper {
+ Base64(Base64),
+ Path(Path),
+ }
+
+ let params = match Helper::deserialize(deserializer)? {
+ Helper::Path(ref mut data) => AddonInstallParameters {
+ path: data.path.clone(),
+ temporary: data.temporary,
+ },
+ Helper::Base64(ref mut data) => {
+ let content = BASE64_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<logging::Level>,
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+ use crate::test::assert_de;
+
+ #[test]
+ fn test_json_addon_install_parameters_invalid() {
+ assert!(serde_json::from_str::<AddonInstallParameters>("").is_err());
+ assert!(serde_json::from_value::<AddonInstallParameters>(json!(null)).is_err());
+ assert!(serde_json::from_value::<AddonInstallParameters>(json!({})).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_path_and_temporary() {
+ let params = AddonInstallParameters {
+ path: "/path/to.xpi".to_string(),
+ temporary: Some(true),
+ };
+ assert_de(&params, 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(&params, json!({"path": "/path/to.xpi"}));
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_path_invalid_type() {
+ let json = json!({"path": true, "temporary": true});
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_path_and_temporary_invalid_type() {
+ let json = json!({"path": "/path/to.xpi", "temporary": "foo"});
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_addon() {
+ let json = json!({"addon": "aGVsbG8=", "temporary": true});
+ let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap();
+
+ assert_eq!(data.temporary, Some(true));
+ let mut file = File::open(data.path).unwrap();
+ let mut contents = String::new();
+ file.read_to_string(&mut contents).unwrap();
+ assert_eq!(contents, "hello");
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_addon_only() {
+ let json = json!({"addon": "aGVsbG8="});
+ let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap();
+
+ assert_eq!(data.temporary, None);
+ let mut file = File::open(data.path).unwrap();
+ let mut contents = String::new();
+ file.read_to_string(&mut contents).unwrap();
+ assert_eq!(contents, "hello");
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_addon_invalid_type() {
+ let json = json!({"addon": true, "temporary": true});
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_addon_and_temporary_invalid_type() {
+ let json = json!({"addon": "aGVsbG8=", "temporary": "foo"});
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_install_parameters_with_temporary_only() {
+ let json = json!({"temporary": true});
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_install_parameters_with_both_path_and_addon() {
+ let json = json!({
+ "path": "/path/to.xpi",
+ "addon": "aGVsbG8=",
+ "temporary": true,
+ });
+ assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_uninstall_parameters_invalid() {
+ assert!(serde_json::from_str::<AddonUninstallParameters>("").is_err());
+ assert!(serde_json::from_value::<AddonUninstallParameters>(json!(null)).is_err());
+ assert!(serde_json::from_value::<AddonUninstallParameters>(json!({})).is_err());
+ }
+
+ #[test]
+ fn test_json_addon_uninstall_parameters() {
+ let params = AddonUninstallParameters {
+ id: "foo".to_string(),
+ };
+ assert_de(&params, json!({"id": "foo"}));
+ }
+
+ #[test]
+ fn test_json_addon_uninstall_parameters_id_invalid_type() {
+ let json = json!({"id": true});
+ assert!(serde_json::from_value::<AddonUninstallParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_gecko_context_parameters_content() {
+ let params = GeckoContextParameters {
+ context: GeckoContext::Content,
+ };
+ assert_de(&params, json!({"context": "content"}));
+ }
+
+ #[test]
+ fn test_json_gecko_context_parameters_chrome() {
+ let params = GeckoContextParameters {
+ context: GeckoContext::Chrome,
+ };
+ assert_de(&params, json!({"context": "chrome"}));
+ }
+
+ #[test]
+ fn test_json_gecko_context_parameters_context_invalid() {
+ type P = GeckoContextParameters;
+ assert!(serde_json::from_value::<P>(json!({})).is_err());
+ assert!(serde_json::from_value::<P>(json!({ "context": null })).is_err());
+ assert!(serde_json::from_value::<P>(json!({"context": "foo"})).is_err());
+ }
+}
diff --git a/testing/geckodriver/src/logging.rs b/testing/geckodriver/src/logging.rs
new file mode 100644
index 0000000000..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<usize> for Level {
+ fn from(n: usize) -> Level {
+ use self::Level::*;
+ match n {
+ 70 => Fatal,
+ 60 => Error,
+ 50 => Warn,
+ 40 => Info,
+ 30 => Config,
+ 20 => Debug,
+ 10 => Trace,
+ _ => Info,
+ }
+ }
+}
+
+impl fmt::Display for Level {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ use self::Level::*;
+ let s = match *self {
+ Fatal => "FATAL",
+ Error => "ERROR",
+ Warn => "WARN",
+ Info => "INFO",
+ Config => "CONFIG",
+ Debug => "DEBUG",
+ Trace => "TRACE",
+ };
+ write!(f, "{}", s)
+ }
+}
+
+impl str::FromStr for Level {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result<Level, ()> {
+ use self::Level::*;
+ match s.to_lowercase().as_ref() {
+ "fatal" => Ok(Fatal),
+ "error" => Ok(Error),
+ "warn" => Ok(Warn),
+ "info" => Ok(Info),
+ "config" => Ok(Config),
+ "debug" => Ok(Debug),
+ "trace" => Ok(Trace),
+ _ => Err(()),
+ }
+ }
+}
+
+impl From<Level> 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<Level> 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<log::Level> for Level {
+ fn from(log_level: log::Level) -> Level {
+ use log::Level::*;
+ match log_level {
+ Error => Level::Error,
+ Warn => Level::Warn,
+ Info => Level::Info,
+ Debug => Level::Debug,
+ Trace => Level::Trace,
+ }
+ }
+}
+
+struct Logger;
+
+impl log::Log for Logger {
+ fn enabled(&self, meta: &log::Metadata) -> bool {
+ LOGGED_TARGETS.iter().any(|&x| meta.target().starts_with(x))
+ && meta.level() <= log::max_level()
+ }
+
+ fn log(&self, record: &log::Record) {
+ if self.enabled(record.metadata()) {
+ 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<chrono::Local>) -> 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::<Vec<&str>>();
+
+ 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::<log::Level>::into(Level::Fatal), log::Level::Error);
+ assert_eq!(Into::<log::Level>::into(Level::Error), log::Level::Error);
+ assert_eq!(Into::<log::Level>::into(Level::Warn), log::Level::Warn);
+ assert_eq!(Into::<log::Level>::into(Level::Info), log::Level::Info);
+ assert_eq!(Into::<log::Level>::into(Level::Config), log::Level::Debug);
+ assert_eq!(Into::<log::Level>::into(Level::Debug), log::Level::Debug);
+ assert_eq!(Into::<log::Level>::into(Level::Trace), log::Level::Trace);
+ }
+
+ #[test]
+ fn test_level_into_pref() {
+ let tests = [
+ (Level::Fatal, "Fatal"),
+ (Level::Error, "Error"),
+ (Level::Warn, "Warn"),
+ (Level::Info, "Info"),
+ (Level::Config, "Config"),
+ (Level::Debug, "Debug"),
+ (Level::Trace, "Trace"),
+ ];
+
+ for &(lvl, s) in tests.iter() {
+ let expected = Pref {
+ value: PrefValue::String(s.to_string()),
+ sticky: false,
+ };
+ assert_eq!(Into::<Pref>::into(lvl), expected);
+ }
+ }
+
+ #[test]
+ fn test_level_from_str() {
+ assert_eq!(Level::from_str("fatal"), Ok(Level::Fatal));
+ assert_eq!(Level::from_str("error"), Ok(Level::Error));
+ assert_eq!(Level::from_str("warn"), Ok(Level::Warn));
+ assert_eq!(Level::from_str("info"), Ok(Level::Info));
+ assert_eq!(Level::from_str("config"), Ok(Level::Config));
+ assert_eq!(Level::from_str("debug"), Ok(Level::Debug));
+ assert_eq!(Level::from_str("trace"), Ok(Level::Trace));
+
+ assert_eq!(Level::from_str("INFO"), Ok(Level::Info));
+
+ assert!(Level::from_str("foo").is_err());
+ }
+
+ #[test]
+ fn test_level_to_str() {
+ assert_eq!(Level::Fatal.to_string(), "FATAL");
+ assert_eq!(Level::Error.to_string(), "ERROR");
+ assert_eq!(Level::Warn.to_string(), "WARN");
+ assert_eq!(Level::Info.to_string(), "INFO");
+ assert_eq!(Level::Config.to_string(), "CONFIG");
+ assert_eq!(Level::Debug.to_string(), "DEBUG");
+ assert_eq!(Level::Trace.to_string(), "TRACE");
+ }
+
+ #[test]
+ fn test_max_level() {
+ let _guard = LEVEL_MUTEX.lock();
+ set_max_level(Level::Info);
+ assert_eq!(max_level(), Level::Info);
+ }
+
+ #[test]
+ fn test_set_max_level() {
+ let _guard = LEVEL_MUTEX.lock();
+ set_max_level(Level::Error);
+ assert_eq!(max_level(), Level::Error);
+ set_max_level(Level::Fatal);
+ assert_eq!(max_level(), Level::Fatal);
+ }
+
+ #[test]
+ fn test_init_with_level() {
+ let _guard = LEVEL_MUTEX.lock();
+ init_with_level(Level::Debug, 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::<String>();
+ // 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::<String>();
+ let part = (0..MAX_STRING_LENGTH / 2).map(|_| "x").collect::<String>();
+
+ // 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<clap::Error> for FatalError {
+ fn from(err: clap::Error) -> FatalError {
+ FatalError::Parsing(err)
+ }
+}
+
+impl From<io::Error> for FatalError {
+ fn from(err: io::Error) -> FatalError {
+ FatalError::Server(err)
+ }
+}
+
+// harmonise error message from clap to avoid duplicate "error:" prefix
+impl fmt::Display for FatalError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ use FatalError::*;
+ let s = match *self {
+ Parsing(ref err) => err.to_string(),
+ Usage(ref s) => format!("error: {}", s),
+ Server(ref err) => format!("error: {}", err),
+ };
+ write!(f, "{}", s)
+ }
+}
+
+macro_rules! usage {
+ ($msg:expr) => {
+ return Err(FatalError::Usage($msg.to_string()))
+ };
+
+ ($fmt:expr, $($arg:tt)+) => {
+ return Err(FatalError::Usage(format!($fmt, $($arg)+)))
+ };
+}
+
+type ProgramResult<T> = result::Result<T, FatalError>;
+
+#[allow(clippy::large_enum_variant)]
+enum Operation {
+ Help,
+ Version,
+ Server {
+ log_level: Option<Level>,
+ log_truncate: bool,
+ address: SocketAddr,
+ allow_hosts: Vec<Host>,
+ allow_origins: Vec<Url>,
+ 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<SocketAddr> {
+ let mut socket_addrs = match format!("{}:{}", webdriver_host, webdriver_port).to_socket_addrs()
+ {
+ Ok(addrs) => addrs.collect::<Vec<_>>(),
+ 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<Host, url::ParseError> {
+ 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<Result<Host, url::ParseError>> {
+ 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<clap::Values>,
+) -> Result<Vec<Host>, url::ParseError> {
+ allow_hosts
+ .map(|hosts| hosts.map(Host::parse).collect::<Vec<_>>())
+ .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::<Result<Vec<Host>, url::ParseError>>()
+}
+
+fn get_allowed_origins(allow_origins: Option<clap::Values>) -> Result<Vec<Url>, url::ParseError> {
+ allow_origins
+ .map(|origins| {
+ origins
+ .map(Url::parse)
+ .collect::<Result<Vec<Url>, url::ParseError>>()
+ })
+ .unwrap_or_else(|| Ok(vec![]))
+}
+
+fn parse_args(cmd: &mut Command) -> ProgramResult<Operation> {
+ 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::<AndroidStorageInput>("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<PathBuf>,
+ pub(crate) profile_root: Option<PathBuf>,
+ pub(crate) connect_existing: bool,
+ pub(crate) host: String,
+ pub(crate) port: Option<u16>,
+ pub(crate) websocket_port: u16,
+ pub(crate) allow_hosts: Vec<Host>,
+ pub(crate) allow_origins: Vec<Url>,
+
+ /// 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<Option<MarionetteConnection>>,
+ settings: MarionetteSettings,
+}
+
+impl MarionetteHandler {
+ pub(crate) fn new(settings: MarionetteSettings) -> MarionetteHandler {
+ MarionetteHandler {
+ connection: Mutex::new(None),
+ settings,
+ }
+ }
+
+ fn create_connection(
+ &self,
+ session_id: Option<String>,
+ new_session_parameters: &NewSessionParameters,
+ ) -> WebDriverResult<MarionetteConnection> {
+ 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<GeckoExtensionRoute> for MarionetteHandler {
+ fn handle_command(
+ &mut self,
+ _: &Option<Session>,
+ msg: WebDriverMessage<GeckoExtensionRoute>,
+ ) -> WebDriverResult<WebDriverResponse> {
+ // 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<String, Value>,
+ command_id: MessageId,
+}
+
+impl MarionetteSession {
+ fn new(session_id: Option<String>, capabilities: Map<String, Value>) -> MarionetteSession {
+ let initital_id = session_id.unwrap_or_default();
+ MarionetteSession {
+ session_id: initital_id,
+ capabilities,
+ command_id: 0,
+ }
+ }
+
+ fn update(
+ &mut self,
+ msg: &WebDriverMessage<GeckoExtensionRoute>,
+ resp: &MarionetteResponse,
+ ) -> WebDriverResult<()> {
+ if let NewSession(_) = msg.command {
+ let session_id = try_opt!(
+ try_opt!(
+ resp.result.get("sessionId"),
+ ErrorStatus::SessionNotCreated,
+ "Unable to get session id"
+ )
+ .as_str(),
+ ErrorStatus::SessionNotCreated,
+ "Unable to convert session id to string"
+ );
+ self.session_id = session_id.to_string();
+ };
+ Ok(())
+ }
+
+ /// Converts a Marionette JSON response into a `WebElement`.
+ ///
+ /// Note that it currently coerces all chrome elements, web frames, and web
+ /// windows also into web elements. This will change at a later point.
+ fn to_web_element(&self, json_data: &Value) -> WebDriverResult<WebElement> {
+ let data = try_opt!(
+ json_data.as_object(),
+ ErrorStatus::UnknownError,
+ "Failed to convert data to an object"
+ );
+
+ let 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<ShadowRoot> {
+ 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<GeckoExtensionRoute>,
+ resp: MarionetteResponse,
+ ) -> WebDriverResult<WebDriverResponse> {
+ use self::GeckoExtensionCommand::*;
+
+ if resp.id != self.command_id {
+ return Err(WebDriverError::new(
+ ErrorStatus::UnknownError,
+ format!(
+ "Marionette responses arrived out of sequence, expected {}, got {}",
+ self.command_id, resp.id
+ ),
+ ));
+ }
+
+ if let Some(error) = resp.error {
+ return Err(error.into());
+ }
+
+ self.update(msg, &resp)?;
+
+ Ok(match msg.command {
+ // Everything that doesn't have a response value
+ Get(_)
+ | GoBack
+ | GoForward
+ | Refresh
+ | SetTimeouts(_)
+ | SwitchToWindow(_)
+ | SwitchToFrame(_)
+ | SwitchToParentFrame
+ | AddCookie(_)
+ | DeleteCookies
+ | DeleteCookie(_)
+ | DismissAlert
+ | AcceptAlert
+ | SendAlertText(_)
+ | ElementClick(_)
+ | ElementClear(_)
+ | ElementSendKeys(_, _)
+ | PerformActions(_)
+ | ReleaseActions => WebDriverResponse::Void,
+ // Things that simply return the contents of the marionette "value" property
+ GetCurrentUrl
+ | GetTitle
+ | GetPageSource
+ | GetWindowHandle
+ | IsDisplayed(_)
+ | IsSelected(_)
+ | GetElementAttribute(_, _)
+ | GetElementProperty(_, _)
+ | GetCSSValue(_, _)
+ | GetElementText(_)
+ | GetElementTagName(_)
+ | 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::<Result<Vec<_>, _>>()?;
+ WebDriverResponse::CloseWindow(CloseWindowResponse(handles))
+ }
+ GetElementRect(_) => {
+ let x = try_opt!(
+ try_opt!(
+ resp.result.get("x"),
+ ErrorStatus::UnknownError,
+ "Failed to find x field"
+ )
+ .as_f64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret x as float"
+ );
+
+ let y = try_opt!(
+ try_opt!(
+ resp.result.get("y"),
+ ErrorStatus::UnknownError,
+ "Failed to find y field"
+ )
+ .as_f64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret y as float"
+ );
+
+ let width = try_opt!(
+ try_opt!(
+ resp.result.get("width"),
+ ErrorStatus::UnknownError,
+ "Failed to find width field"
+ )
+ .as_f64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret width as float"
+ );
+
+ let height = try_opt!(
+ try_opt!(
+ resp.result.get("height"),
+ ErrorStatus::UnknownError,
+ "Failed to find height field"
+ )
+ .as_f64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret width as float"
+ );
+
+ let rect = ElementRectResponse {
+ x,
+ y,
+ width,
+ height,
+ };
+ WebDriverResponse::ElementRect(rect)
+ }
+ FullscreenWindow | MinimizeWindow | MaximizeWindow | GetWindowRect
+ | SetWindowRect(_) => {
+ let width = try_opt!(
+ try_opt!(
+ resp.result.get("width"),
+ ErrorStatus::UnknownError,
+ "Failed to find width field"
+ )
+ .as_u64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret width as positive integer"
+ );
+
+ let height = try_opt!(
+ try_opt!(
+ resp.result.get("height"),
+ ErrorStatus::UnknownError,
+ "Failed to find heigenht field"
+ )
+ .as_u64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret height as positive integer"
+ );
+
+ let x = try_opt!(
+ try_opt!(
+ resp.result.get("x"),
+ ErrorStatus::UnknownError,
+ "Failed to find x field"
+ )
+ .as_i64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret x as integer"
+ );
+
+ let y = try_opt!(
+ try_opt!(
+ resp.result.get("y"),
+ ErrorStatus::UnknownError,
+ "Failed to find y field"
+ )
+ .as_i64(),
+ ErrorStatus::UnknownError,
+ "Failed to interpret y as integer"
+ );
+
+ let rect = WindowRectResponse {
+ x: x as i32,
+ y: y as i32,
+ width: width as i32,
+ height: height as i32,
+ };
+ WebDriverResponse::WindowRect(rect)
+ }
+ GetCookies => {
+ let cookies: Vec<Cookie> = serde_json::from_value(resp.result)?;
+ WebDriverResponse::Cookies(CookiesResponse(cookies))
+ }
+ GetNamedCookie(ref name) => {
+ let mut cookies: Vec<Cookie> = serde_json::from_value(resp.result)?;
+ cookies.retain(|x| x.name == *name);
+ let cookie = try_opt!(
+ cookies.pop(),
+ ErrorStatus::NoSuchCookie,
+ format!("No cookie with name {}", name)
+ );
+ WebDriverResponse::Cookie(CookieResponse(cookie))
+ }
+ FindElement(_) | FindElementElement(_, _) | 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::<Result<Vec<_>, _>>()?;
+
+ // 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<GeckoExtensionRoute>,
+ browser: &Browser,
+) -> WebDriverResult<Option<Command>> {
+ use self::GeckoExtensionCommand::*;
+ use self::WebDriverCommand::*;
+
+ Ok(match msg.command {
+ AcceptAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::AcceptAlert)),
+ AddCookie(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::AddCookie(
+ x.to_marionette()?,
+ ))),
+ CloseWindow => Some(Command::WebDriver(MarionetteWebDriverCommand::CloseWindow)),
+ DeleteCookie(ref x) => Some(Command::WebDriver(
+ MarionetteWebDriverCommand::DeleteCookie(x.clone()),
+ )),
+ DeleteCookies => Some(Command::WebDriver(
+ MarionetteWebDriverCommand::DeleteCookies,
+ )),
+ DeleteSession => 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<String, Value>,
+}
+
+impl Serialize for MarionetteCommand {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let data = (&0, &self.id, &self.name, &self.params);
+ data.serialize(serializer)
+ }
+}
+
+impl MarionetteCommand {
+ fn new(id: MessageId, name: String, params: Map<String, Value>) -> MarionetteCommand {
+ MarionetteCommand { id, name, params }
+ }
+
+ fn encode_msg<T>(msg: T) -> WebDriverResult<String>
+ where
+ T: serde::Serialize,
+ {
+ let data = serde_json::to_string(&msg)?;
+
+ Ok(format!("{}:{}", data.len(), data))
+ }
+
+ fn from_webdriver_message(
+ id: MessageId,
+ capabilities: &Map<String, Value>,
+ browser: &Browser,
+ msg: &WebDriverMessage<GeckoExtensionRoute>,
+ ) -> WebDriverResult<String> {
+ 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<MarionetteError>,
+ result: Value,
+}
+
+impl<'de> Deserialize<'de> for MarionetteResponse {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ struct ResponseWrapper {
+ msg_type: u64,
+ id: MessageId,
+ error: Option<MarionetteError>,
+ result: Value,
+ }
+
+ let wrapper: ResponseWrapper = Deserialize::deserialize(deserializer)?;
+
+ if wrapper.msg_type != 1 {
+ return Err(de::Error::custom(
+ "Expected '1' in first element of response",
+ ));
+ };
+
+ Ok(MarionetteResponse {
+ id: wrapper.id,
+ error: wrapper.error,
+ result: wrapper.result,
+ })
+ }
+}
+
+impl MarionetteResponse {
+ fn into_value_response(self, value_required: bool) -> WebDriverResult<ValueResponse> {
+ let value: &Value = if value_required {
+ try_opt!(
+ self.result.get("value"),
+ ErrorStatus::UnknownError,
+ "Failed to find value field"
+ )
+ } else {
+ &self.result
+ };
+
+ Ok(ValueResponse(value.clone()))
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+struct MarionetteError {
+ #[serde(rename = "error")]
+ code: String,
+ message: String,
+ stacktrace: Option<String>,
+}
+
+impl From<MarionetteError> 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<u16> {
+ 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<MarionetteConnection> {
+ 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<TcpStream> {
+ 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<TcpStream> {
+ let mut stream = TcpStream::connect((host, port))?;
+ MarionetteConnection::handshake(&mut stream)?;
+ Ok(stream)
+ }
+
+ fn handshake(stream: &mut TcpStream) -> WebDriverResult<MarionetteHandshake> {
+ let resp = (match stream.read_timeout() {
+ Ok(timeout) => {
+ // If platform supports changing the read timeout of the stream,
+ // use a short one only for the handshake with Marionette. 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::<MarionetteHandshake>(&resp)?;
+
+ if data.application_type != "gecko" {
+ return Err(WebDriverError::new(
+ ErrorStatus::UnknownError,
+ format!("Unrecognized application type {}", data.application_type),
+ ));
+ }
+
+ if data.protocol != 3 {
+ return Err(WebDriverError::new(
+ ErrorStatus::UnknownError,
+ format!(
+ "Unsupported Marionette protocol version {}, required 3",
+ data.protocol
+ ),
+ ));
+ }
+
+ Ok(data)
+ }
+
+ 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<GeckoExtensionRoute>,
+ ) -> WebDriverResult<WebDriverResponse> {
+ 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<String> {
+ 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<String> {
+ 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<T> {
+ fn to_marionette(&self) -> WebDriverResult<T>;
+}
+
+impl ToMarionette<Map<String, Value>> for AddonInstallParameters {
+ fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> {
+ let mut data = Map::new();
+ data.insert("path".to_string(), serde_json::to_value(&self.path)?);
+ if self.temporary.is_some() {
+ data.insert(
+ "temporary".to_string(),
+ serde_json::to_value(self.temporary)?,
+ );
+ }
+ Ok(data)
+ }
+}
+
+impl ToMarionette<Map<String, Value>> for AddonUninstallParameters {
+ fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> {
+ let mut data = Map::new();
+ data.insert("id".to_string(), Value::String(self.id.clone()));
+ Ok(data)
+ }
+}
+
+impl ToMarionette<Map<String, Value>> for GeckoContextParameters {
+ fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> {
+ let mut data = Map::new();
+ data.insert(
+ "value".to_owned(),
+ serde_json::to_value(self.context.clone())?,
+ );
+ Ok(data)
+ }
+}
+
+impl ToMarionette<MarionettePrintParameters> for PrintParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionettePrintParameters> {
+ Ok(MarionettePrintParameters {
+ orientation: self.orientation.to_marionette()?,
+ scale: self.scale,
+ background: self.background,
+ page: self.page.to_marionette()?,
+ margin: self.margin.to_marionette()?,
+ page_ranges: self.page_ranges.clone(),
+ shrink_to_fit: self.shrink_to_fit,
+ })
+ }
+}
+
+impl ToMarionette<MarionettePrintOrientation> for PrintOrientation {
+ fn to_marionette(&self) -> WebDriverResult<MarionettePrintOrientation> {
+ Ok(match self {
+ PrintOrientation::Landscape => MarionettePrintOrientation::Landscape,
+ PrintOrientation::Portrait => MarionettePrintOrientation::Portrait,
+ })
+ }
+}
+
+impl ToMarionette<MarionettePrintPage> for PrintPage {
+ fn to_marionette(&self) -> WebDriverResult<MarionettePrintPage> {
+ Ok(MarionettePrintPage {
+ width: self.width,
+ height: self.height,
+ })
+ }
+}
+
+impl ToMarionette<MarionettePrintMargins> for PrintMargins {
+ fn to_marionette(&self) -> WebDriverResult<MarionettePrintMargins> {
+ Ok(MarionettePrintMargins {
+ top: self.top,
+ bottom: self.bottom,
+ left: self.left,
+ right: self.right,
+ })
+ }
+}
+
+impl ToMarionette<Map<String, Value>> for ActionsParameters {
+ fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> {
+ Ok(try_opt!(
+ serde_json::to_value(self)?.as_object(),
+ ErrorStatus::UnknownError,
+ "Expected an object"
+ )
+ .clone())
+ }
+}
+
+impl ToMarionette<MarionetteCookie> for AddCookieParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteCookie> {
+ Ok(MarionetteCookie {
+ name: self.name.clone(),
+ value: self.value.clone(),
+ path: self.path.clone(),
+ domain: self.domain.clone(),
+ secure: self.secure,
+ http_only: self.httpOnly,
+ expiry: match &self.expiry {
+ Some(date) => Some(date.to_marionette()?),
+ None => None,
+ },
+ same_site: self.sameSite.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteDate> for Date {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteDate> {
+ Ok(MarionetteDate(self.0))
+ }
+}
+
+impl ToMarionette<Map<String, Value>> for GetNamedCookieParameters {
+ fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> {
+ Ok(try_opt!(
+ serde_json::to_value(self)?.as_object(),
+ ErrorStatus::UnknownError,
+ "Expected an object"
+ )
+ .clone())
+ }
+}
+
+impl ToMarionette<MarionetteUrl> for GetParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteUrl> {
+ Ok(MarionetteUrl {
+ url: self.url.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteScript> for JavascriptCommandParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteScript> {
+ Ok(MarionetteScript {
+ script: self.script.clone(),
+ args: self.args.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteLocator> for LocatorParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteLocator> {
+ Ok(MarionetteLocator {
+ using: self.using.to_marionette()?,
+ value: self.value.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteSelector> for LocatorStrategy {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteSelector> {
+ use self::LocatorStrategy::*;
+ match self {
+ CSSSelector => Ok(MarionetteSelector::Css),
+ LinkText => Ok(MarionetteSelector::LinkText),
+ PartialLinkText => Ok(MarionetteSelector::PartialLinkText),
+ TagName => Ok(MarionetteSelector::TagName),
+ XPath => Ok(MarionetteSelector::XPath),
+ }
+ }
+}
+
+impl ToMarionette<MarionetteNewWindow> for NewWindowParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteNewWindow> {
+ Ok(MarionetteNewWindow {
+ type_hint: self.type_hint.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteKeys> for SendKeysParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteKeys> {
+ Ok(MarionetteKeys {
+ text: self.text.clone(),
+ value: self
+ .text
+ .chars()
+ .map(|x| x.to_string())
+ .collect::<Vec<String>>(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteFrame> for SwitchToFrameParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteFrame> {
+ Ok(match &self.id {
+ Some(x) => match x {
+ FrameId::Short(n) => MarionetteFrame::Index(*n),
+ FrameId::Element(el) => MarionetteFrame::Element(el.0.clone()),
+ },
+ None => MarionetteFrame::Parent,
+ })
+ }
+}
+
+impl ToMarionette<Window> for SwitchToWindowParameters {
+ fn to_marionette(&self) -> WebDriverResult<Window> {
+ Ok(Window {
+ handle: self.handle.clone(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteTimeouts> for TimeoutsParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteTimeouts> {
+ Ok(MarionetteTimeouts {
+ implicit: self.implicit,
+ page_load: self.page_load,
+ script: self.script,
+ })
+ }
+}
+
+impl ToMarionette<MarionetteWebElement> for WebElement {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteWebElement> {
+ Ok(MarionetteWebElement {
+ element: self.to_string(),
+ })
+ }
+}
+
+impl ToMarionette<MarionetteWindowRect> for WindowRectParameters {
+ fn to_marionette(&self) -> WebDriverResult<MarionetteWindowRect> {
+ Ok(MarionetteWindowRect {
+ x: self.x,
+ y: self.y,
+ width: self.width,
+ height: self.height,
+ })
+ }
+}
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<T>(data: &T, json: serde_json::Value)
+where
+ T: std::fmt::Debug,
+ T: std::cmp::PartialEq,
+ T: serde::de::DeserializeOwned,
+{
+ assert_eq!(data, &serde_json::from_value::<T>(json).unwrap());
+}
diff --git a/testing/geckodriver/src/tests/profile.zip b/testing/geckodriver/src/tests/profile.zip
new file mode 100644
index 0000000000..286b118183
--- /dev/null
+++ b/testing/geckodriver/src/tests/profile.zip
Binary files differ