summaryrefslogtreecommitdiffstats
path: root/testing/webdriver
diff options
context:
space:
mode:
Diffstat (limited to 'testing/webdriver')
-rw-r--r--testing/webdriver/Cargo.toml39
-rw-r--r--testing/webdriver/README.md44
-rw-r--r--testing/webdriver/moz.build8
-rw-r--r--testing/webdriver/src/actions.rs1397
-rw-r--r--testing/webdriver/src/capabilities.rs823
-rw-r--r--testing/webdriver/src/command.rs1710
-rw-r--r--testing/webdriver/src/common.rs319
-rw-r--r--testing/webdriver/src/error.rs405
-rw-r--r--testing/webdriver/src/httpapi.rs451
-rw-r--r--testing/webdriver/src/lib.rs39
-rw-r--r--testing/webdriver/src/macros.rs12
-rw-r--r--testing/webdriver/src/response.rs332
-rw-r--r--testing/webdriver/src/server.rs691
-rw-r--r--testing/webdriver/src/test.rs32
14 files changed, 6302 insertions, 0 deletions
diff --git a/testing/webdriver/Cargo.toml b/testing/webdriver/Cargo.toml
new file mode 100644
index 0000000000..a36fb16d7e
--- /dev/null
+++ b/testing/webdriver/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+edition = "2021"
+name = "webdriver"
+version = "0.50.0"
+authors = ["Mozilla"]
+include = ["/src"]
+description = "Library implementing the wire protocol for the W3C WebDriver specification."
+documentation = "https://docs.rs/webdriver"
+readme = "README.md"
+keywords = [
+ "automation",
+ "browser",
+ "protocol",
+ "w3c",
+ "webdriver",
+]
+license = "MPL-2.0"
+repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/webdriver"
+
+[features]
+default = ["server"]
+server = ["tokio", "tokio-stream", "warp"]
+
+[dependencies]
+base64 = "0.21"
+bytes = "1.0"
+cookie = { version = "0.16", default-features = false }
+http = "0.2"
+icu_segmenter = { version = "1.4", default-features = false, features = ["auto", "compiled_data"] }
+log = "0.4"
+serde = "1.0"
+serde_json = "1.0"
+serde_derive = "1.0"
+time = "0.3"
+tokio = { version = "1.0", features = ["rt", "net"], optional = true}
+tokio-stream = { version = "0.1", features = ["net"], optional = true}
+url = "2.4"
+thiserror = "1"
+warp = { version = "0.3", default-features = false, optional = true }
diff --git a/testing/webdriver/README.md b/testing/webdriver/README.md
new file mode 100644
index 0000000000..1eccdfd26c
--- /dev/null
+++ b/testing/webdriver/README.md
@@ -0,0 +1,44 @@
+webdriver library
+=================
+
+The [webdriver crate] is a library implementation of the wire protocol
+for the [W3C WebDriver standard] written in Rust. WebDriver is a remote
+control interface that enables introspection and control of user agents.
+It provides a platform- and language-neutral wire protocol as a way
+for out-of-process programs to remotely instruct the behaviour of web
+browsers.
+
+The webdriver library provides the formal types, error codes, type and
+bounds checks, and JSON marshaling conventions for correctly parsing
+and emitting the WebDriver protocol. It also provides an HTTP server
+where endpoints are mapped to the different WebDriver commands.
+
+**As of right now, this is an implementation for the server side of the
+WebDriver API in Rust, not the client side.**
+
+[webdriver crate]: https://crates.io/crates/webdriver
+[W3C WebDriver standard]: https://w3c.github.io/webdriver/
+
+
+Building
+========
+
+The library is built using the usual [Rust conventions]:
+
+ % cargo build
+
+To run the tests:
+
+ % cargo test
+
+[Rust conventions]: http://doc.crates.io/guide.html
+
+
+Contact
+=======
+
+The mailing list for webdriver discussion is
+https://groups.google.com/a/mozilla.org/g/dev-webdriver.
+
+There is also an Element channel to talk about using and developing
+webdriver on `#webdriver:mozilla.org <https://chat.mozilla.org/#/room/#webdriver:mozilla.org>`__
diff --git a/testing/webdriver/moz.build b/testing/webdriver/moz.build
new file mode 100644
index 0000000000..88b907e6f2
--- /dev/null
+++ b/testing/webdriver/moz.build
@@ -0,0 +1,8 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Testing", "geckodriver")
diff --git a/testing/webdriver/src/actions.rs b/testing/webdriver/src/actions.rs
new file mode 100644
index 0000000000..31c0c0375e
--- /dev/null
+++ b/testing/webdriver/src/actions.rs
@@ -0,0 +1,1397 @@
+/* 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::common::{WebElement, ELEMENT_KEY};
+use icu_segmenter::GraphemeClusterSegmenter;
+use serde::de::{self, Deserialize, Deserializer};
+use serde::ser::{Serialize, Serializer};
+use serde_json::Value;
+use std::default::Default;
+use std::f64;
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct ActionSequence {
+ pub id: String,
+ #[serde(flatten)]
+ pub actions: ActionsType,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum ActionsType {
+ #[serde(rename = "none")]
+ Null { actions: Vec<NullActionItem> },
+ #[serde(rename = "key")]
+ Key { actions: Vec<KeyActionItem> },
+ #[serde(rename = "pointer")]
+ Pointer {
+ #[serde(default)]
+ parameters: PointerActionParameters,
+ actions: Vec<PointerActionItem>,
+ },
+ #[serde(rename = "wheel")]
+ Wheel { actions: Vec<WheelActionItem> },
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum NullActionItem {
+ General(GeneralAction),
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum GeneralAction {
+ #[serde(rename = "pause")]
+ Pause(PauseAction),
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct PauseAction {
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub duration: Option<u64>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum KeyActionItem {
+ General(GeneralAction),
+ Key(KeyAction),
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum KeyAction {
+ #[serde(rename = "keyDown")]
+ Down(KeyDownAction),
+ #[serde(rename = "keyUp")]
+ Up(KeyUpAction),
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct KeyDownAction {
+ #[serde(deserialize_with = "deserialize_key_action_value")]
+ pub value: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct KeyUpAction {
+ #[serde(deserialize_with = "deserialize_key_action_value")]
+ pub value: String,
+}
+
+fn deserialize_key_action_value<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ String::deserialize(deserializer).map(|value| {
+ // Only a single Unicode grapheme cluster is allowed
+ if GraphemeClusterSegmenter::new().segment_str(&value).count() != 2 {
+ return Err(de::Error::custom(format!(
+ "'{}' should only contain a single Unicode code point",
+ value
+ )));
+ }
+
+ Ok(value)
+ })?
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PointerType {
+ #[default]
+ Mouse,
+ Pen,
+ Touch,
+}
+
+
+
+#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct PointerActionParameters {
+ #[serde(rename = "pointerType")]
+ pub pointer_type: PointerType,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum PointerActionItem {
+ General(GeneralAction),
+ Pointer(PointerAction),
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum PointerAction {
+ #[serde(rename = "pointerCancel")]
+ Cancel,
+ #[serde(rename = "pointerDown")]
+ Down(PointerDownAction),
+ #[serde(rename = "pointerMove")]
+ Move(PointerMoveAction),
+ #[serde(rename = "pointerUp")]
+ Up(PointerUpAction),
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct PointerDownAction {
+ pub button: u64,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub width: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub height: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_pressure"
+ )]
+ pub pressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tangential_pressure"
+ )]
+ pub tangentialPressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltX: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltY: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_twist"
+ )]
+ pub twist: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_altitude_angle"
+ )]
+ pub altitudeAngle: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_azimuth_angle"
+ )]
+ pub azimuthAngle: Option<f64>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct PointerMoveAction {
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub duration: Option<u64>,
+ #[serde(default)]
+ pub origin: PointerOrigin,
+ pub x: i64,
+ pub y: i64,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub width: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub height: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_pressure"
+ )]
+ pub pressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tangential_pressure"
+ )]
+ pub tangentialPressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltX: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltY: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_twist"
+ )]
+ pub twist: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_altitude_angle"
+ )]
+ pub altitudeAngle: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_azimuth_angle"
+ )]
+ pub azimuthAngle: Option<f64>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct PointerUpAction {
+ pub button: u64,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub width: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub height: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_pressure"
+ )]
+ pub pressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tangential_pressure"
+ )]
+ pub tangentialPressure: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltX: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_tilt"
+ )]
+ pub tiltY: Option<i64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_twist"
+ )]
+ pub twist: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_altitude_angle"
+ )]
+ pub altitudeAngle: Option<f64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_azimuth_angle"
+ )]
+ pub azimuthAngle: Option<f64>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize)]
+pub enum PointerOrigin {
+ #[serde(
+ rename = "element-6066-11e4-a52e-4f735466cecf",
+ serialize_with = "serialize_webelement_id"
+ )]
+ Element(WebElement),
+ #[serde(rename = "pointer")]
+ Pointer,
+ #[serde(rename = "viewport")]
+ #[default]
+ Viewport,
+}
+
+
+
+// TODO: The custom deserializer can be removed once the support of the legacy
+// ELEMENT key has been removed from Selenium bindings
+// See: https://github.com/SeleniumHQ/selenium/issues/6393
+impl<'de> Deserialize<'de> for PointerOrigin {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = Value::deserialize(deserializer)?;
+ if let Some(web_element) = value.get(ELEMENT_KEY) {
+ String::deserialize(web_element)
+ .map(|id| PointerOrigin::Element(WebElement(id)))
+ .map_err(de::Error::custom)
+ } else if value == "pointer" {
+ Ok(PointerOrigin::Pointer)
+ } else if value == "viewport" {
+ Ok(PointerOrigin::Viewport)
+ } else {
+ Err(de::Error::custom(format!(
+ "unknown value `{}`, expected `pointer`, `viewport`, or `element-6066-11e4-a52e-4f735466cecf`",
+ value
+ )))
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum WheelActionItem {
+ General(GeneralAction),
+ Wheel(WheelAction),
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum WheelAction {
+ #[serde(rename = "scroll")]
+ Scroll(WheelScrollAction),
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct WheelScrollAction {
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_option_u64"
+ )]
+ pub duration: Option<u64>,
+ #[serde(default)]
+ pub origin: PointerOrigin,
+ pub x: Option<i64>,
+ pub y: Option<i64>,
+ pub deltaX: Option<i64>,
+ pub deltaY: Option<i64>,
+}
+
+fn serialize_webelement_id<S>(element: &WebElement, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ element.to_string().serialize(serializer)
+}
+
+fn deserialize_to_option_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ Option::deserialize(deserializer)?
+ .ok_or_else(|| de::Error::custom("invalid type: null, expected i64"))
+}
+
+fn deserialize_to_option_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ Option::deserialize(deserializer)?
+ .ok_or_else(|| de::Error::custom("invalid type: null, expected i64"))
+}
+
+fn deserialize_to_option_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ Option::deserialize(deserializer)?
+ .ok_or_else(|| de::Error::custom("invalid type: null, expected f64"))
+}
+
+fn deserialize_to_pressure<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_f64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(0f64..=1.0).contains(&value) {
+ return Err(de::Error::custom(format!("{} is outside range 0-1", value)));
+ }
+ };
+ Ok(opt_value)
+}
+
+fn deserialize_to_tangential_pressure<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_f64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(-1.0..=1.0).contains(&value) {
+ return Err(de::Error::custom(format!(
+ "{} is outside range -1-1",
+ value
+ )));
+ }
+ };
+ Ok(opt_value)
+}
+
+fn deserialize_to_tilt<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_i64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(-90..=90).contains(&value) {
+ return Err(de::Error::custom(format!(
+ "{} is outside range -90-90",
+ value
+ )));
+ }
+ };
+ Ok(opt_value)
+}
+
+fn deserialize_to_twist<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_u64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(0..=359).contains(&value) {
+ return Err(de::Error::custom(format!(
+ "{} is outside range 0-359",
+ value
+ )));
+ }
+ };
+ Ok(opt_value)
+}
+
+fn deserialize_to_altitude_angle<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_f64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(0f64..=f64::consts::FRAC_PI_2).contains(&value) {
+ return Err(de::Error::custom(format!(
+ "{} is outside range 0-PI/2",
+ value
+ )));
+ }
+ };
+ Ok(opt_value)
+}
+
+fn deserialize_to_azimuth_angle<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt_value = deserialize_to_option_f64(deserializer)?;
+ if let Some(value) = opt_value {
+ if !(0f64..=f64::consts::TAU).contains(&value) {
+ return Err(de::Error::custom(format!(
+ "{} is outside range 0-2*PI",
+ value
+ )));
+ }
+ };
+ Ok(opt_value)
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use crate::test::{assert_de, assert_ser_de};
+ use serde_json::{self, json, Value};
+
+ #[test]
+ fn test_json_action_sequence_null() {
+ let json = json!({
+ "id": "some_key",
+ "type": "none",
+ "actions": [{
+ "type": "pause",
+ "duration": 1,
+ }]
+ });
+ let seq = ActionSequence {
+ id: "some_key".into(),
+ actions: ActionsType::Null {
+ actions: vec![NullActionItem::General(GeneralAction::Pause(PauseAction {
+ duration: Some(1),
+ }))],
+ },
+ };
+
+ assert_ser_de(&seq, json);
+ }
+
+ #[test]
+ fn test_json_action_sequence_key() {
+ let json = json!({
+ "id": "some_key",
+ "type": "key",
+ "actions": [
+ {"type": "keyDown", "value": "f"},
+ ],
+ });
+ let seq = ActionSequence {
+ id: "some_key".into(),
+ actions: ActionsType::Key {
+ actions: vec![KeyActionItem::Key(KeyAction::Down(KeyDownAction {
+ value: String::from("f"),
+ }))],
+ },
+ };
+
+ assert_ser_de(&seq, json);
+ }
+
+ #[test]
+ fn test_json_action_sequence_pointer() {
+ let json = json!({
+ "id": "some_pointer",
+ "type": "pointer",
+ "parameters": {
+ "pointerType": "mouse"
+ },
+ "actions": [
+ {"type": "pointerDown", "button": 0},
+ {"type": "pointerMove", "origin": "pointer", "x": 10, "y": 20},
+ {"type": "pointerUp", "button": 0},
+ ]
+ });
+ let seq = ActionSequence {
+ id: "some_pointer".into(),
+ actions: ActionsType::Pointer {
+ parameters: PointerActionParameters {
+ pointer_type: PointerType::Mouse,
+ },
+ actions: vec![
+ PointerActionItem::Pointer(PointerAction::Down(PointerDownAction {
+ button: 0,
+ ..Default::default()
+ })),
+ PointerActionItem::Pointer(PointerAction::Move(PointerMoveAction {
+ origin: PointerOrigin::Pointer,
+ duration: None,
+ x: 10,
+ y: 20,
+ ..Default::default()
+ })),
+ PointerActionItem::Pointer(PointerAction::Up(PointerUpAction {
+ button: 0,
+ ..Default::default()
+ })),
+ ],
+ },
+ };
+
+ assert_ser_de(&seq, json);
+ }
+
+ #[test]
+ fn test_json_action_sequence_id_missing() {
+ let json = json!({
+ "type": "key",
+ "actions": [],
+ });
+ assert!(serde_json::from_value::<ActionSequence>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_action_sequence_id_null() {
+ let json = json!({
+ "id": null,
+ "type": "key",
+ "actions": [],
+ });
+ assert!(serde_json::from_value::<ActionSequence>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_action_sequence_actions_missing() {
+ assert!(serde_json::from_value::<ActionSequence>(json!({"id": "3"})).is_err());
+ }
+
+ #[test]
+ fn test_json_action_sequence_actions_null() {
+ let json = json!({
+ "id": "3",
+ "actions": null,
+ });
+ assert!(serde_json::from_value::<ActionSequence>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_action_sequence_actions_invalid_type() {
+ let json = json!({
+ "id": "3",
+ "actions": "foo",
+ });
+ assert!(serde_json::from_value::<ActionSequence>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_actions_type_null() {
+ let json = json!({
+ "type": "none",
+ "actions": [{
+ "type": "pause",
+ "duration": 1,
+ }],
+ });
+ let null = ActionsType::Null {
+ actions: vec![NullActionItem::General(GeneralAction::Pause(PauseAction {
+ duration: Some(1),
+ }))],
+ };
+
+ assert_ser_de(&null, json);
+ }
+
+ #[test]
+ fn test_json_actions_type_key() {
+ let json = json!({
+ "type": "key",
+ "actions": [{
+ "type": "keyDown",
+ "value": "f",
+ }],
+ });
+ let key = ActionsType::Key {
+ actions: vec![KeyActionItem::Key(KeyAction::Down(KeyDownAction {
+ value: String::from("f"),
+ }))],
+ };
+
+ assert_ser_de(&key, json);
+ }
+
+ #[test]
+ fn test_json_actions_type_pointer() {
+ let json = json!({
+ "type": "pointer",
+ "parameters": {"pointerType": "mouse"},
+ "actions": [
+ {"type": "pointerDown", "button": 1},
+ ]});
+ let pointer = ActionsType::Pointer {
+ parameters: PointerActionParameters {
+ pointer_type: PointerType::Mouse,
+ },
+ actions: vec![PointerActionItem::Pointer(PointerAction::Down(
+ PointerDownAction {
+ button: 1,
+ ..Default::default()
+ },
+ ))],
+ };
+
+ assert_ser_de(&pointer, json);
+ }
+
+ #[test]
+ fn test_json_actions_type_pointer_with_parameters_missing() {
+ let json = json!({
+ "type": "pointer",
+ "actions": [
+ {"type": "pointerDown", "button": 1},
+ ]});
+ let pointer = ActionsType::Pointer {
+ parameters: PointerActionParameters {
+ pointer_type: PointerType::Mouse,
+ },
+ actions: vec![PointerActionItem::Pointer(PointerAction::Down(
+ PointerDownAction {
+ button: 1,
+ ..Default::default()
+ },
+ ))],
+ };
+
+ assert_de(&pointer, json);
+ }
+
+ #[test]
+ fn test_json_actions_type_pointer_with_parameters_invalid_type() {
+ let json = json!({
+ "type": "pointer",
+ "parameters": null,
+ "actions": [
+ {"type":"pointerDown", "button": 1},
+ ]});
+ assert!(serde_json::from_value::<ActionsType>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_actions_type_invalid() {
+ let json = json!({"actions": [{"foo": "bar"}]});
+ assert!(serde_json::from_value::<ActionsType>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_null_action_item_general() {
+ let pause =
+ NullActionItem::General(GeneralAction::Pause(PauseAction { duration: Some(1) }));
+ assert_ser_de(&pause, json!({"type": "pause", "duration": 1}));
+ }
+
+ #[test]
+ fn test_json_null_action_item_invalid_type() {
+ assert!(serde_json::from_value::<NullActionItem>(json!({"type": "invalid"})).is_err());
+ }
+
+ #[test]
+ fn test_json_general_action_pause() {
+ let pause = GeneralAction::Pause(PauseAction { duration: Some(1) });
+ assert_ser_de(&pause, json!({"type": "pause", "duration": 1}));
+ }
+
+ #[test]
+ fn test_json_general_action_pause_with_duration_missing() {
+ let pause = GeneralAction::Pause(PauseAction { duration: None });
+ assert_ser_de(&pause, json!({"type": "pause"}));
+ }
+
+ #[test]
+ fn test_json_general_action_pause_with_duration_null() {
+ let json = json!({"type": "pause", "duration": null});
+ assert!(serde_json::from_value::<GeneralAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_general_action_pause_with_duration_invalid_type() {
+ let json = json!({"type": "pause", "duration":" foo"});
+ assert!(serde_json::from_value::<GeneralAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_general_action_pause_with_duration_negative() {
+ let json = json!({"type": "pause", "duration": -30});
+ assert!(serde_json::from_value::<GeneralAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_item_general() {
+ let pause = KeyActionItem::General(GeneralAction::Pause(PauseAction { duration: Some(1) }));
+ assert_ser_de(&pause, json!({"type": "pause", "duration": 1}));
+ }
+
+ #[test]
+ fn test_json_key_action_item_key() {
+ let key_down = KeyActionItem::Key(KeyAction::Down(KeyDownAction {
+ value: String::from("f"),
+ }));
+ assert_ser_de(&key_down, json!({"type": "keyDown", "value": "f"}));
+ }
+
+ #[test]
+ fn test_json_key_action_item_invalid_type() {
+ assert!(serde_json::from_value::<KeyActionItem>(json!({"type": "invalid"})).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_missing_subtype() {
+ assert!(serde_json::from_value::<KeyAction>(json!({"value": "f"})).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_wrong_subtype() {
+ let json = json!({"type": "pause", "value": "f"});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_down() {
+ let key_down = KeyAction::Down(KeyDownAction {
+ value: "f".to_string(),
+ });
+ assert_ser_de(&key_down, json!({"type": "keyDown", "value": "f"}));
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_value_unicode() {
+ let key_down = KeyAction::Down(KeyDownAction {
+ value: "à".to_string(),
+ });
+ assert_ser_de(&key_down, json!({"type": "keyDown", "value": "à"}));
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_value_unicode_encoded() {
+ let key_down = KeyAction::Down(KeyDownAction {
+ value: "à".to_string(),
+ });
+ assert_de(&key_down, json!({"type": "keyDown", "value": "\u{00E0}"}));
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_value_missing() {
+ assert!(serde_json::from_value::<KeyAction>(json!({"type": "keyDown"})).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_value_null() {
+ let json = json!({"type": "keyDown", "value": null});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_value_invalid_type() {
+ let json = json!({"type": "keyDown", "value": ["f", "o", "o"]});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_down_with_multiple_code_points() {
+ let json = json!({"type": "keyDown", "value": "fo"});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_up() {
+ let key_up = KeyAction::Up(KeyUpAction {
+ value: "f".to_string(),
+ });
+ assert_ser_de(&key_up, json!({"type": "keyUp", "value": "f"}));
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_value_unicode() {
+ let key_up = KeyAction::Up(KeyUpAction {
+ value: "à".to_string(),
+ });
+ assert_ser_de(&key_up, json!({"type":"keyUp", "value": "à"}));
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_value_unicode_encoded() {
+ let key_up = KeyAction::Up(KeyUpAction {
+ value: "à".to_string(),
+ });
+ assert_de(&key_up, json!({"type": "keyUp", "value": "\u{00E0}"}));
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_value_missing() {
+ assert!(serde_json::from_value::<KeyAction>(json!({"type": "keyUp"})).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_value_null() {
+ let json = json!({"type": "keyUp", "value": null});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_value_invalid_type() {
+ let json = json!({"type": "keyUp", "value": ["f","o","o"]});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_key_action_up_with_multiple_code_points() {
+ let json = json!({"type": "keyUp", "value": "fo"});
+ assert!(serde_json::from_value::<KeyAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_item_general() {
+ let pause =
+ PointerActionItem::General(GeneralAction::Pause(PauseAction { duration: Some(1) }));
+ assert_ser_de(&pause, json!({"type": "pause", "duration": 1}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_item_pointer() {
+ let cancel = PointerActionItem::Pointer(PointerAction::Cancel);
+ assert_ser_de(&cancel, json!({"type": "pointerCancel"}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_item_invalid() {
+ assert!(serde_json::from_value::<PointerActionItem>(json!({"type": "invalid"})).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_parameters_mouse() {
+ let mouse = PointerActionParameters {
+ pointer_type: PointerType::Mouse,
+ };
+ assert_ser_de(&mouse, json!({"pointerType": "mouse"}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_parameters_pen() {
+ let pen = PointerActionParameters {
+ pointer_type: PointerType::Pen,
+ };
+ assert_ser_de(&pen, json!({"pointerType": "pen"}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_parameters_touch() {
+ let touch = PointerActionParameters {
+ pointer_type: PointerType::Touch,
+ };
+ assert_ser_de(&touch, json!({"pointerType": "touch"}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_item_invalid_type() {
+ let json = json!({"type": "pointerInvalid"});
+ assert!(serde_json::from_value::<PointerActionItem>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_missing_subtype() {
+ assert!(serde_json::from_value::<PointerAction>(json!({"button": 1})).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_invalid_subtype() {
+ let json = json!({"type": "invalid", "button": 1});
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_cancel() {
+ assert_ser_de(&PointerAction::Cancel, json!({"type": "pointerCancel"}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_down() {
+ let pointer_down = PointerAction::Down(PointerDownAction {
+ button: 1,
+ ..Default::default()
+ });
+ assert_ser_de(&pointer_down, json!({"type": "pointerDown", "button": 1}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_down_with_button_missing() {
+ let json = json!({"type": "pointerDown"});
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_down_with_button_null() {
+ let json = json!({
+ "type": "pointerDown",
+ "button": null,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_down_with_button_invalid_type() {
+ let json = json!({
+ "type": "pointerDown",
+ "button": "foo",
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_down_with_button_negative() {
+ let json = json!({
+ "type": "pointerDown",
+ "button": -30,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ let pointer_move = PointerAction::Move(PointerMoveAction {
+ duration: Some(100),
+ origin: PointerOrigin::Viewport,
+ x: 5,
+ y: 10,
+ ..Default::default()
+ });
+
+ assert_ser_de(&pointer_move, json);
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_missing_subtype() {
+ let json = json!({
+ "duration": 100,
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_wrong_subtype() {
+ let json = json!({
+ "type": "pointerUp",
+ "duration": 100,
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_duration_missing() {
+ let json = json!({
+ "type": "pointerMove",
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ let pointer_move = PointerAction::Move(PointerMoveAction {
+ duration: None,
+ origin: PointerOrigin::Viewport,
+ x: 5,
+ y: 10,
+ ..Default::default()
+ });
+
+ assert_ser_de(&pointer_move, json);
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_duration_null() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": null,
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_duration_invalid_type() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": "invalid",
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_duration_negative() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": -30,
+ "origin": "viewport",
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_origin_missing() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "x": 5,
+ "y": 10,
+ });
+ let pointer_move = PointerAction::Move(PointerMoveAction {
+ duration: Some(100),
+ origin: PointerOrigin::Viewport,
+ x: 5,
+ y: 10,
+ ..Default::default()
+ });
+
+ assert_de(&pointer_move, json);
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_origin_webelement() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": {ELEMENT_KEY: "elem"},
+ "x": 5,
+ "y": 10,
+ });
+ let pointer_move = PointerAction::Move(PointerMoveAction {
+ duration: Some(100),
+ origin: PointerOrigin::Element(WebElement("elem".into())),
+ x: 5,
+ y: 10,
+ ..Default::default()
+ });
+
+ assert_ser_de(&pointer_move, json);
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_origin_webelement_and_legacy_element() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": {ELEMENT_KEY: "elem"},
+ "x": 5,
+ "y": 10,
+ });
+ let pointer_move = PointerAction::Move(PointerMoveAction {
+ duration: Some(100),
+ origin: PointerOrigin::Element(WebElement("elem".into())),
+ x: 5,
+ y: 10,
+ ..Default::default()
+ });
+
+ assert_de(&pointer_move, json);
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_origin_only_legacy_element() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": {ELEMENT_KEY: "elem"},
+ "x": 5,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerOrigin>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_x_null() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": "viewport",
+ "x": null,
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_x_invalid_type() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": "viewport",
+ "x": "invalid",
+ "y": 10,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_y_null() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": "viewport",
+ "x": 5,
+ "y": null,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_move_with_y_invalid_type() {
+ let json = json!({
+ "type": "pointerMove",
+ "duration": 100,
+ "origin": "viewport",
+ "x": 5,
+ "y": "invalid",
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_up() {
+ let pointer_up = PointerAction::Up(PointerUpAction {
+ button: 1,
+ ..Default::default()
+ });
+ assert_ser_de(&pointer_up, json!({"type": "pointerUp", "button": 1}));
+ }
+
+ #[test]
+ fn test_json_pointer_action_up_with_button_missing() {
+ assert!(serde_json::from_value::<PointerAction>(json!({"type": "pointerUp"})).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_up_with_button_null() {
+ let json = json!({
+ "type": "pointerUp",
+ "button": null,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_up_with_button_invalid_type() {
+ let json = json!({
+ "type": "pointerUp",
+ "button": "foo",
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_action_up_with_button_negative() {
+ let json = json!({
+ "type": "pointerUp",
+ "button": -30,
+ });
+ assert!(serde_json::from_value::<PointerAction>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_origin_pointer() {
+ assert_ser_de(&PointerOrigin::Pointer, json!("pointer"));
+ }
+
+ #[test]
+ fn test_json_pointer_origin_viewport() {
+ assert_ser_de(&PointerOrigin::Viewport, json!("viewport"));
+ }
+
+ #[test]
+ fn test_json_pointer_origin_web_element() {
+ let element = PointerOrigin::Element(WebElement("elem".into()));
+ assert_ser_de(&element, json!({ELEMENT_KEY: "elem"}));
+ }
+
+ #[test]
+ fn test_json_pointer_origin_invalid_type() {
+ assert!(serde_json::from_value::<PointerOrigin>(json!("invalid")).is_err());
+ }
+
+ #[test]
+ fn test_json_pointer_type_mouse() {
+ assert_ser_de(&PointerType::Mouse, json!("mouse"));
+ }
+
+ #[test]
+ fn test_json_pointer_type_pen() {
+ assert_ser_de(&PointerType::Pen, json!("pen"));
+ }
+
+ #[test]
+ fn test_json_pointer_type_touch() {
+ assert_ser_de(&PointerType::Touch, json!("touch"));
+ }
+
+ #[test]
+ fn test_json_pointer_type_invalid_type() {
+ assert!(serde_json::from_value::<PointerType>(json!("invalid")).is_err());
+ }
+
+ #[test]
+ fn test_pointer_properties() {
+ // Ideally these would be seperate tests, but it was too much boilerplate to write
+ // and adding a macro seemed like overkill.
+ for actionType in ["pointerUp", "pointerDown", "pointerMove"] {
+ for (prop_name, value, is_valid) in [
+ ("pressure", Value::from(0), true),
+ ("pressure", Value::from(0.5), true),
+ ("pressure", Value::from(1), true),
+ ("pressure", Value::from(1.1), false),
+ ("pressure", Value::from(-0.1), false),
+ ("tangentialPressure", Value::from(-1), true),
+ ("tangentialPressure", Value::from(0), true),
+ ("tangentialPressure", Value::from(1.0), true),
+ ("tangentialPressure", Value::from(-1.1), false),
+ ("tangentialPressure", Value::from(1.1), false),
+ ("tiltX", Value::from(-90), true),
+ ("tiltX", Value::from(0), true),
+ ("tiltX", Value::from(45), true),
+ ("tiltX", Value::from(90), true),
+ ("tiltX", Value::from(0.5), false),
+ ("tiltX", Value::from(-91), false),
+ ("tiltX", Value::from(91), false),
+ ("tiltY", Value::from(-90), true),
+ ("tiltY", Value::from(0), true),
+ ("tiltY", Value::from(45), true),
+ ("tiltY", Value::from(90), true),
+ ("tiltY", Value::from(0.5), false),
+ ("tiltY", Value::from(-91), false),
+ ("tiltY", Value::from(91), false),
+ ("twist", Value::from(0), true),
+ ("twist", Value::from(180), true),
+ ("twist", Value::from(359), true),
+ ("twist", Value::from(360), false),
+ ("twist", Value::from(-1), false),
+ ("twist", Value::from(23.5), false),
+ ("altitudeAngle", Value::from(0), true),
+ ("altitudeAngle", Value::from(f64::consts::FRAC_PI_4), true),
+ ("altitudeAngle", Value::from(f64::consts::FRAC_PI_2), true),
+ (
+ "altitudeAngle",
+ Value::from(f64::consts::FRAC_PI_2 + 0.1),
+ false,
+ ),
+ ("altitudeAngle", Value::from(-f64::consts::FRAC_PI_4), false),
+ ("azimuthAngle", Value::from(0), true),
+ ("azimuthAngle", Value::from(f64::consts::PI), true),
+ ("azimuthAngle", Value::from(f64::consts::TAU), true),
+ ("azimuthAngle", Value::from(f64::consts::TAU + 0.01), false),
+ ("azimuthAngle", Value::from(-f64::consts::FRAC_PI_4), false),
+ ] {
+ let mut json = serde_json::Map::new();
+ json.insert("type".into(), actionType.into());
+ if actionType != "pointerMove" {
+ json.insert("button".into(), Value::from(0));
+ } else {
+ json.insert("x".into(), Value::from(0));
+ json.insert("y".into(), Value::from(0));
+ }
+ json.insert(prop_name.into(), value);
+ println!("{:?}", json);
+ let deserialized = serde_json::from_value::<PointerAction>(json.into());
+ if is_valid {
+ assert!(deserialized.is_ok());
+ } else {
+ assert!(deserialized.is_err());
+ }
+ }
+ }
+ }
+}
diff --git a/testing/webdriver/src/capabilities.rs b/testing/webdriver/src/capabilities.rs
new file mode 100644
index 0000000000..ee588f93fb
--- /dev/null
+++ b/testing/webdriver/src/capabilities.rs
@@ -0,0 +1,823 @@
+/* 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::common::MAX_SAFE_INTEGER;
+use crate::error::{ErrorStatus, WebDriverError, WebDriverResult};
+use serde_json::{Map, Value};
+use url::Url;
+
+pub type Capabilities = Map<String, Value>;
+
+/// Trait for objects that can be used to inspect browser capabilities
+///
+/// The main methods in this trait are called with a Capabilites object
+/// resulting from a full set of potential capabilites for the session. Given
+/// those Capabilities they return a property of the browser instance that
+/// would be initiated. In many cases this will be independent of the input,
+/// but in the case of e.g. browser version, it might depend on a path to the
+/// binary provided as a capability.
+pub trait BrowserCapabilities {
+ /// Set up the Capabilites object
+ ///
+ /// Typically used to create any internal caches
+ fn init(&mut self, _: &Capabilities);
+
+ /// Name of the browser
+ fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>>;
+
+ /// Version number of the browser
+ fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>>;
+
+ /// Compare actual browser version to that provided in a version specifier
+ ///
+ /// Parameters are the actual browser version and the comparison string,
+ /// respectively. The format of the comparison string is
+ /// implementation-defined.
+ fn compare_browser_version(&mut self, version: &str, comparison: &str)
+ -> WebDriverResult<bool>;
+
+ /// Name of the platform/OS
+ fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>>;
+
+ /// Whether insecure certificates are supported
+ fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether driver supports all of the window resizing and
+ /// repositioning commands.
+ fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates that interactability checks will be applied to `<input type=file>`.
+ fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Whether a WebSocket URL for the created session has to be returned
+ fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether the endpoint node supports all Virtual Authenticators commands.
+ fn webauthn_virtual_authenticators(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether the endpoint node WebAuthn WebDriver implementation supports the User
+ /// Verification Method extension.
+ fn webauthn_extension_uvm(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether the endpoint node WebAuthn WebDriver implementation supports the prf
+ /// extension.
+ fn webauthn_extension_prf(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether the endpoint node WebAuthn WebDriver implementation supports the
+ /// largeBlob extension.
+ fn webauthn_extension_large_blob(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ /// Indicates whether the endpoint node WebAuthn WebDriver implementation supports the credBlob
+ /// extension.
+ fn webauthn_extension_cred_blob(&mut self, _: &Capabilities) -> WebDriverResult<bool>;
+
+ fn accept_proxy(
+ &mut self,
+ proxy_settings: &Map<String, Value>,
+ _: &Capabilities,
+ ) -> WebDriverResult<bool>;
+
+ /// Type check custom properties
+ ///
+ /// Check that custom properties containing ":" have the correct data types.
+ /// Properties that are unrecognised must be ignored i.e. return without
+ /// error.
+ fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()>;
+
+ /// Check if custom properties are accepted capabilites
+ ///
+ /// Check that custom properties containing ":" are compatible with
+ /// the implementation.
+ fn accept_custom(
+ &mut self,
+ name: &str,
+ value: &Value,
+ merged: &Capabilities,
+ ) -> WebDriverResult<bool>;
+}
+
+/// Trait to abstract over various version of the new session parameters
+///
+/// This trait is expected to be implemented on objects holding the capabilities
+/// from a new session command.
+pub trait CapabilitiesMatching {
+ /// Match the BrowserCapabilities against some candidate capabilites
+ ///
+ /// Takes a BrowserCapabilites object and returns a set of capabilites that
+ /// are valid for that browser, if any, or None if there are no matching
+ /// capabilities.
+ fn match_browser<T: BrowserCapabilities>(
+ &self,
+ browser_capabilities: &mut T,
+ ) -> WebDriverResult<Option<Capabilities>>;
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct SpecNewSessionParameters {
+ #[serde(default = "Capabilities::default")]
+ pub alwaysMatch: Capabilities,
+ #[serde(default = "firstMatch_default")]
+ pub firstMatch: Vec<Capabilities>,
+}
+
+impl Default for SpecNewSessionParameters {
+ fn default() -> Self {
+ SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ }
+ }
+}
+
+fn firstMatch_default() -> Vec<Capabilities> {
+ vec![Capabilities::default()]
+}
+
+impl SpecNewSessionParameters {
+ fn validate<T: BrowserCapabilities>(
+ &self,
+ mut capabilities: Capabilities,
+ browser_capabilities: &mut T,
+ ) -> WebDriverResult<Capabilities> {
+ // Filter out entries with the value `null`
+ let null_entries = capabilities
+ .iter()
+ .filter(|&(_, value)| *value == Value::Null)
+ .map(|(k, _)| k.clone())
+ .collect::<Vec<String>>();
+ for key in null_entries {
+ capabilities.remove(&key);
+ }
+
+ for (key, value) in &capabilities {
+ match &**key {
+ x @ "acceptInsecureCerts"
+ | x @ "setWindowRect"
+ | x @ "strictFileInteractability"
+ | x @ "webSocketUrl"
+ | x @ "webauthn:virtualAuthenticators"
+ | x @ "webauthn:extension:uvm"
+ | x @ "webauthn:extension:prf"
+ | x @ "webauthn:extension:largeBlob"
+ | x @ "webauthn:extension:credBlob" => {
+ if !value.is_boolean() {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not boolean: {}", x, value),
+ ));
+ }
+ }
+ x @ "browserName" | x @ "browserVersion" | x @ "platformName" => {
+ if !value.is_string() {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a string: {}", x, value),
+ ));
+ }
+ }
+ "pageLoadStrategy" => SpecNewSessionParameters::validate_page_load_strategy(value)?,
+ "proxy" => SpecNewSessionParameters::validate_proxy(value)?,
+ "timeouts" => SpecNewSessionParameters::validate_timeouts(value)?,
+ "unhandledPromptBehavior" => {
+ SpecNewSessionParameters::validate_unhandled_prompt_behaviour(value)?
+ }
+ x => {
+ if !x.contains(':') {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!(
+ "{} is not the name of a known capability or extension capability",
+ x
+ ),
+ ));
+ } else {
+ browser_capabilities.validate_custom(x, value)?
+ }
+ }
+ }
+ }
+
+ // With a value of `false` the capability needs to be removed.
+ if let Some(Value::Bool(false)) = capabilities.get(&"webSocketUrl".to_string()) {
+ capabilities.remove(&"webSocketUrl".to_string());
+ }
+
+ Ok(capabilities)
+ }
+
+ fn validate_page_load_strategy(value: &Value) -> WebDriverResult<()> {
+ match value {
+ Value::String(x) => match &**x {
+ "normal" | "eager" | "none" => {}
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid page load strategy: {}", x),
+ ))
+ }
+ },
+ _ => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "pageLoadStrategy is not a string",
+ ))
+ }
+ }
+ Ok(())
+ }
+
+ fn validate_proxy(proxy_value: &Value) -> WebDriverResult<()> {
+ let obj = try_opt!(
+ proxy_value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "proxy is not an object"
+ );
+
+ for (key, value) in obj {
+ match &**key {
+ "proxyType" => match value.as_str() {
+ Some("pac") | Some("direct") | Some("autodetect") | Some("system")
+ | Some("manual") => {}
+ Some(x) => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid proxyType value: {}", x),
+ ))
+ }
+ None => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("proxyType is not a string: {}", value),
+ ))
+ }
+ },
+
+ "proxyAutoconfigUrl" => match value.as_str() {
+ Some(x) => {
+ Url::parse(x).map_err(|_| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("proxyAutoconfigUrl is not a valid URL: {}", x),
+ )
+ })?;
+ }
+ None => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "proxyAutoconfigUrl is not a string",
+ ))
+ }
+ },
+
+ "ftpProxy" => SpecNewSessionParameters::validate_host(value, "ftpProxy")?,
+ "httpProxy" => SpecNewSessionParameters::validate_host(value, "httpProxy")?,
+ "noProxy" => SpecNewSessionParameters::validate_no_proxy(value)?,
+ "sslProxy" => SpecNewSessionParameters::validate_host(value, "sslProxy")?,
+ "socksProxy" => SpecNewSessionParameters::validate_host(value, "socksProxy")?,
+ "socksVersion" => {
+ if !value.is_number() {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("socksVersion is not a number: {}", value),
+ ));
+ }
+ }
+
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid proxy configuration entry: {}", x),
+ ))
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn validate_no_proxy(value: &Value) -> WebDriverResult<()> {
+ match value.as_array() {
+ Some(hosts) => {
+ for host in hosts {
+ match host.as_str() {
+ Some(_) => {}
+ None => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("noProxy item is not a string: {}", host),
+ ))
+ }
+ }
+ }
+ }
+ None => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("noProxy is not an array: {}", value),
+ ))
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Validate whether a named capability is JSON value is a string
+ /// containing a host and possible port
+ fn validate_host(value: &Value, entry: &str) -> WebDriverResult<()> {
+ match value.as_str() {
+ Some(host) => {
+ if host.contains("://") {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} must not contain a scheme: {}", entry, host),
+ ));
+ }
+
+ // Temporarily add a scheme so the host can be parsed as URL
+ let url = Url::parse(&format!("http://{}", host)).map_err(|_| {
+ WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a valid URL: {}", entry, host),
+ )
+ })?;
+
+ if url.username() != ""
+ || url.password().is_some()
+ || url.path() != "/"
+ || url.query().is_some()
+ || url.fragment().is_some()
+ {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not of the form host[:port]: {}", entry, host),
+ ));
+ }
+ }
+
+ None => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("{} is not a string: {}", entry, value),
+ ))
+ }
+ }
+
+ Ok(())
+ }
+
+ fn validate_timeouts(value: &Value) -> WebDriverResult<()> {
+ let obj = try_opt!(
+ value.as_object(),
+ ErrorStatus::InvalidArgument,
+ "timeouts capability is not an object"
+ );
+
+ for (key, value) in obj {
+ match &**key {
+ _x @ "script" if value.is_null() => {}
+
+ x @ "script" | x @ "pageLoad" | x @ "implicit" => {
+ let timeout = try_opt!(
+ value.as_f64(),
+ ErrorStatus::InvalidArgument,
+ format!("{} timeouts value is not a number: {}", x, value)
+ );
+ if timeout < 0.0 || timeout.fract() != 0.0 {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!(
+ "'{}' timeouts value is not a positive Integer: {}",
+ x, timeout
+ ),
+ ));
+ }
+ if (timeout as u64) > MAX_SAFE_INTEGER {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!(
+ "'{}' timeouts value is greater than maximum safe integer: {}",
+ x, timeout
+ ),
+ ));
+ }
+ }
+
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid timeouts capability entry: {}", x),
+ ))
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn validate_unhandled_prompt_behaviour(value: &Value) -> WebDriverResult<()> {
+ let behaviour = try_opt!(
+ value.as_str(),
+ ErrorStatus::InvalidArgument,
+ format!("unhandledPromptBehavior is not a string: {}", value)
+ );
+
+ match behaviour {
+ "accept" | "accept and notify" | "dismiss" | "dismiss and notify" | "ignore" => {}
+ x => {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("Invalid unhandledPromptBehavior value: {}", x),
+ ))
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl CapabilitiesMatching for SpecNewSessionParameters {
+ fn match_browser<T: BrowserCapabilities>(
+ &self,
+ browser_capabilities: &mut T,
+ ) -> WebDriverResult<Option<Capabilities>> {
+ let default = vec![Map::new()];
+ let capabilities_list = if self.firstMatch.is_empty() {
+ &default
+ } else {
+ &self.firstMatch
+ };
+
+ let merged_capabilities = capabilities_list
+ .iter()
+ .map(|first_match_entry| {
+ if first_match_entry
+ .keys()
+ .any(|k| self.alwaysMatch.contains_key(k))
+ {
+ return Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "firstMatch key shadowed a value in alwaysMatch",
+ ));
+ }
+ let mut merged = self.alwaysMatch.clone();
+ for (key, value) in first_match_entry.clone() {
+ merged.insert(key, value);
+ }
+ Ok(merged)
+ })
+ .map(|merged| merged.and_then(|x| self.validate(x, browser_capabilities)))
+ .collect::<WebDriverResult<Vec<Capabilities>>>()?;
+
+ let selected = merged_capabilities
+ .iter()
+ .find(|merged| {
+ browser_capabilities.init(merged);
+
+ for (key, value) in merged.iter() {
+ match &**key {
+ "browserName" => {
+ let browserValue = browser_capabilities
+ .browser_name(merged)
+ .ok()
+ .and_then(|x| x);
+
+ if value.as_str() != browserValue.as_deref() {
+ return false;
+ }
+ }
+ "browserVersion" => {
+ let browserValue = browser_capabilities
+ .browser_version(merged)
+ .ok()
+ .and_then(|x| x);
+ // We already validated this was a string
+ let version_cond = value.as_str().unwrap_or("");
+ if let Some(version) = browserValue {
+ if !browser_capabilities
+ .compare_browser_version(&version, version_cond)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ "platformName" => {
+ let browserValue = browser_capabilities
+ .platform_name(merged)
+ .ok()
+ .and_then(|x| x);
+ if value.as_str() != browserValue.as_deref() {
+ return false;
+ }
+ }
+ "acceptInsecureCerts" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .accept_insecure_certs(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "setWindowRect" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .set_window_rect(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "strictFileInteractability" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .strict_file_interactability(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "proxy" => {
+ let default = Map::new();
+ let proxy = value.as_object().unwrap_or(&default);
+ if !browser_capabilities
+ .accept_proxy(proxy, merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webSocketUrl" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities.web_socket_url(merged).unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webauthn:virtualAuthenticators" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .webauthn_virtual_authenticators(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webauthn:extension:uvm" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .webauthn_extension_uvm(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webauthn:extension:prf" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .webauthn_extension_prf(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webauthn:extension:largeBlob" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .webauthn_extension_large_blob(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ "webauthn:extension:credBlob" => {
+ if value.as_bool().unwrap_or(false)
+ && !browser_capabilities
+ .webauthn_extension_cred_blob(merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ }
+ name => {
+ if name.contains(':') {
+ if !browser_capabilities
+ .accept_custom(name, value, merged)
+ .unwrap_or(false)
+ {
+ return false;
+ }
+ } else {
+ // Accept the capability
+ }
+ }
+ }
+ }
+
+ true
+ })
+ .cloned();
+ Ok(selected)
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct LegacyNewSessionParameters {
+ #[serde(rename = "desiredCapabilities", default = "Capabilities::default")]
+ pub desired: Capabilities,
+ #[serde(rename = "requiredCapabilities", default = "Capabilities::default")]
+ pub required: Capabilities,
+}
+
+impl CapabilitiesMatching for LegacyNewSessionParameters {
+ fn match_browser<T: BrowserCapabilities>(
+ &self,
+ browser_capabilities: &mut T,
+ ) -> WebDriverResult<Option<Capabilities>> {
+ // For now don't do anything much, just merge the
+ // desired and required and return the merged list.
+
+ let mut capabilities: Capabilities = Map::new();
+ self.required.iter().chain(self.desired.iter()).fold(
+ &mut capabilities,
+ |caps, (key, value)| {
+ if !caps.contains_key(key) {
+ caps.insert(key.clone(), value.clone());
+ }
+ caps
+ },
+ );
+ browser_capabilities.init(&capabilities);
+ Ok(Some(capabilities))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test::assert_de;
+ use serde_json::{self, json};
+
+ #[test]
+ fn test_json_spec_new_session_parameters_alwaysMatch_only() {
+ let caps = SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ };
+ assert_de(&caps, json!({"alwaysMatch": {}}));
+ }
+
+ #[test]
+ fn test_json_spec_new_session_parameters_firstMatch_only() {
+ let caps = SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ };
+ assert_de(&caps, json!({"firstMatch": [{}]}));
+ }
+
+ #[test]
+ fn test_json_spec_new_session_parameters_alwaysMatch_null() {
+ let json = json!({
+ "alwaysMatch": null,
+ "firstMatch": [{}],
+ });
+ assert!(serde_json::from_value::<SpecNewSessionParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_spec_new_session_parameters_firstMatch_null() {
+ let json = json!({
+ "alwaysMatch": {},
+ "firstMatch": null,
+ });
+ assert!(serde_json::from_value::<SpecNewSessionParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_spec_new_session_parameters_both_empty() {
+ let json = json!({
+ "alwaysMatch": {},
+ "firstMatch": [{}],
+ });
+ let caps = SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ };
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_spec_new_session_parameters_both_with_capability() {
+ let json = json!({
+ "alwaysMatch": {"foo": "bar"},
+ "firstMatch": [{"foo2": "bar2"}],
+ });
+ let mut caps = SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ };
+ caps.alwaysMatch.insert("foo".into(), "bar".into());
+ caps.firstMatch[0].insert("foo2".into(), "bar2".into());
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_desired_only() {
+ let caps = LegacyNewSessionParameters {
+ desired: Capabilities::new(),
+ required: Capabilities::new(),
+ };
+ assert_de(&caps, json!({"desiredCapabilities": {}}));
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_required_only() {
+ let caps = LegacyNewSessionParameters {
+ desired: Capabilities::new(),
+ required: Capabilities::new(),
+ };
+ assert_de(&caps, json!({"requiredCapabilities": {}}));
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_desired_null() {
+ let json = json!({
+ "desiredCapabilities": null,
+ "requiredCapabilities": {},
+ });
+ assert!(serde_json::from_value::<LegacyNewSessionParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_required_null() {
+ let json = json!({
+ "desiredCapabilities": {},
+ "requiredCapabilities": null,
+ });
+ assert!(serde_json::from_value::<LegacyNewSessionParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_both_empty() {
+ let json = json!({
+ "desiredCapabilities": {},
+ "requiredCapabilities": {},
+ });
+ let caps = LegacyNewSessionParameters {
+ desired: Capabilities::new(),
+ required: Capabilities::new(),
+ };
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_spec_legacy_new_session_parameters_both_with_capabilities() {
+ let json = json!({
+ "desiredCapabilities": {"foo": "bar"},
+ "requiredCapabilities": {"foo2": "bar2"},
+ });
+ let mut caps = LegacyNewSessionParameters {
+ desired: Capabilities::new(),
+ required: Capabilities::new(),
+ };
+ caps.desired.insert("foo".into(), "bar".into());
+ caps.required.insert("foo2".into(), "bar2".into());
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_validate_proxy() {
+ fn validate_proxy(v: Value) -> WebDriverResult<()> {
+ SpecNewSessionParameters::validate_proxy(&v)
+ }
+
+ // proxy hosts
+ validate_proxy(json!({"httpProxy": "127.0.0.1"})).unwrap();
+ validate_proxy(json!({"httpProxy": "127.0.0.1:"})).unwrap();
+ validate_proxy(json!({"httpProxy": "127.0.0.1:3128"})).unwrap();
+ validate_proxy(json!({"httpProxy": "localhost"})).unwrap();
+ validate_proxy(json!({"httpProxy": "localhost:3128"})).unwrap();
+ validate_proxy(json!({"httpProxy": "[2001:db8::1]"})).unwrap();
+ validate_proxy(json!({"httpProxy": "[2001:db8::1]:3128"})).unwrap();
+ validate_proxy(json!({"httpProxy": "example.org"})).unwrap();
+ validate_proxy(json!({"httpProxy": "example.org:3128"})).unwrap();
+
+ assert!(validate_proxy(json!({"httpProxy": "http://example.org"})).is_err());
+ assert!(validate_proxy(json!({"httpProxy": "example.org:-1"})).is_err());
+ assert!(validate_proxy(json!({"httpProxy": "2001:db8::1"})).is_err());
+
+ // no proxy for manual proxy type
+ validate_proxy(json!({"noProxy": ["foo"]})).unwrap();
+
+ assert!(validate_proxy(json!({"noProxy": "foo"})).is_err());
+ assert!(validate_proxy(json!({"noProxy": [42]})).is_err());
+ }
+}
diff --git a/testing/webdriver/src/command.rs b/testing/webdriver/src/command.rs
new file mode 100644
index 0000000000..3f531ebd3e
--- /dev/null
+++ b/testing/webdriver/src/command.rs
@@ -0,0 +1,1710 @@
+/* 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::actions::ActionSequence;
+use crate::capabilities::{
+ BrowserCapabilities, Capabilities, CapabilitiesMatching, LegacyNewSessionParameters,
+ SpecNewSessionParameters,
+};
+use crate::common::{
+ CredentialParameters, Date, FrameId, LocatorStrategy, ShadowRoot, WebElement, MAX_SAFE_INTEGER,
+};
+use crate::error::{ErrorStatus, WebDriverError, WebDriverResult};
+use crate::httpapi::{Route, VoidWebDriverExtensionRoute, WebDriverExtensionRoute};
+use crate::Parameters;
+use serde::de::{self, Deserialize, Deserializer};
+use serde_json::{self, Value};
+
+#[derive(Debug, PartialEq)]
+pub enum WebDriverCommand<T: WebDriverExtensionCommand> {
+ NewSession(NewSessionParameters),
+ DeleteSession,
+ Get(GetParameters),
+ GetCurrentUrl,
+ GoBack,
+ GoForward,
+ Refresh,
+ GetTitle,
+ GetPageSource,
+ GetWindowHandle,
+ GetWindowHandles,
+ NewWindow(NewWindowParameters),
+ CloseWindow,
+ GetWindowRect,
+ SetWindowRect(WindowRectParameters),
+ MinimizeWindow,
+ MaximizeWindow,
+ FullscreenWindow,
+ SwitchToWindow(SwitchToWindowParameters),
+ SwitchToFrame(SwitchToFrameParameters),
+ SwitchToParentFrame,
+ FindElement(LocatorParameters),
+ FindElements(LocatorParameters),
+ FindElementElement(WebElement, LocatorParameters),
+ FindElementElements(WebElement, LocatorParameters),
+ FindShadowRootElement(ShadowRoot, LocatorParameters),
+ FindShadowRootElements(ShadowRoot, LocatorParameters),
+ GetActiveElement,
+ GetComputedLabel(WebElement),
+ GetComputedRole(WebElement),
+ GetShadowRoot(WebElement),
+ IsDisplayed(WebElement),
+ IsSelected(WebElement),
+ GetElementAttribute(WebElement, String),
+ GetElementProperty(WebElement, String),
+ GetCSSValue(WebElement, String),
+ GetElementText(WebElement),
+ GetElementTagName(WebElement),
+ GetElementRect(WebElement),
+ IsEnabled(WebElement),
+ ExecuteScript(JavascriptCommandParameters),
+ ExecuteAsyncScript(JavascriptCommandParameters),
+ GetCookies,
+ GetNamedCookie(String),
+ AddCookie(AddCookieParameters),
+ DeleteCookies,
+ DeleteCookie(String),
+ GetTimeouts,
+ SetTimeouts(TimeoutsParameters),
+ ElementClick(WebElement),
+ ElementClear(WebElement),
+ ElementSendKeys(WebElement, SendKeysParameters),
+ PerformActions(ActionsParameters),
+ ReleaseActions,
+ DismissAlert,
+ AcceptAlert,
+ GetAlertText,
+ SendAlertText(SendKeysParameters),
+ TakeScreenshot,
+ TakeElementScreenshot(WebElement),
+ Print(PrintParameters),
+ Status,
+ Extension(T),
+ WebAuthnAddVirtualAuthenticator(AuthenticatorParameters),
+ WebAuthnRemoveVirtualAuthenticator,
+ WebAuthnAddCredential(CredentialParameters),
+ WebAuthnGetCredentials,
+ WebAuthnRemoveCredential,
+ WebAuthnRemoveAllCredentials,
+ WebAuthnSetUserVerified(UserVerificationParameters),
+}
+
+pub trait WebDriverExtensionCommand: Clone + Send {
+ fn parameters_json(&self) -> Option<Value>;
+}
+
+#[derive(Clone, Debug)]
+pub struct VoidWebDriverExtensionCommand;
+
+impl WebDriverExtensionCommand for VoidWebDriverExtensionCommand {
+ fn parameters_json(&self) -> Option<Value> {
+ panic!("No extensions implemented");
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub struct WebDriverMessage<U: WebDriverExtensionRoute = VoidWebDriverExtensionRoute> {
+ pub session_id: Option<String>,
+ pub command: WebDriverCommand<U::Command>,
+}
+
+impl<U: WebDriverExtensionRoute> WebDriverMessage<U> {
+ pub fn new(
+ session_id: Option<String>,
+ command: WebDriverCommand<U::Command>,
+ ) -> WebDriverMessage<U> {
+ WebDriverMessage {
+ session_id,
+ command,
+ }
+ }
+
+ pub fn from_http(
+ match_type: Route<U>,
+ params: &Parameters,
+ raw_body: &str,
+ requires_body: bool,
+ ) -> WebDriverResult<WebDriverMessage<U>> {
+ let session_id = WebDriverMessage::<U>::get_session_id(params);
+ let body_data = WebDriverMessage::<U>::decode_body(raw_body, requires_body)?;
+ let command = match match_type {
+ Route::NewSession => WebDriverCommand::NewSession(serde_json::from_str(raw_body)?),
+ Route::DeleteSession => WebDriverCommand::DeleteSession,
+ Route::Get => WebDriverCommand::Get(serde_json::from_str(raw_body)?),
+ Route::GetCurrentUrl => WebDriverCommand::GetCurrentUrl,
+ Route::GoBack => WebDriverCommand::GoBack,
+ Route::GoForward => WebDriverCommand::GoForward,
+ Route::Refresh => WebDriverCommand::Refresh,
+ Route::GetTitle => WebDriverCommand::GetTitle,
+ Route::GetPageSource => WebDriverCommand::GetPageSource,
+ Route::GetWindowHandle => WebDriverCommand::GetWindowHandle,
+ Route::GetWindowHandles => WebDriverCommand::GetWindowHandles,
+ Route::NewWindow => WebDriverCommand::NewWindow(serde_json::from_str(raw_body)?),
+ Route::CloseWindow => WebDriverCommand::CloseWindow,
+ Route::GetTimeouts => WebDriverCommand::GetTimeouts,
+ Route::SetTimeouts => WebDriverCommand::SetTimeouts(serde_json::from_str(raw_body)?),
+ Route::GetWindowRect | Route::GetWindowPosition | Route::GetWindowSize => {
+ WebDriverCommand::GetWindowRect
+ }
+ Route::SetWindowRect | Route::SetWindowPosition | Route::SetWindowSize => {
+ WebDriverCommand::SetWindowRect(serde_json::from_str(raw_body)?)
+ }
+ Route::MinimizeWindow => WebDriverCommand::MinimizeWindow,
+ Route::MaximizeWindow => WebDriverCommand::MaximizeWindow,
+ Route::FullscreenWindow => WebDriverCommand::FullscreenWindow,
+ Route::SwitchToWindow => {
+ WebDriverCommand::SwitchToWindow(serde_json::from_str(raw_body)?)
+ }
+ Route::SwitchToFrame => {
+ WebDriverCommand::SwitchToFrame(serde_json::from_str(raw_body)?)
+ }
+ Route::SwitchToParentFrame => WebDriverCommand::SwitchToParentFrame,
+ Route::FindElement => WebDriverCommand::FindElement(serde_json::from_str(raw_body)?),
+ Route::FindElements => WebDriverCommand::FindElements(serde_json::from_str(raw_body)?),
+ Route::FindElementElement => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::FindElementElement(element, serde_json::from_str(raw_body)?)
+ }
+ Route::FindElementElements => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::FindElementElements(element, serde_json::from_str(raw_body)?)
+ }
+ Route::FindShadowRootElement => {
+ let shadow_id = try_opt!(
+ params.get("shadowId"),
+ ErrorStatus::InvalidArgument,
+ "Missing shadowId parameter"
+ );
+ let shadow_root = ShadowRoot(shadow_id.as_str().into());
+ WebDriverCommand::FindShadowRootElement(
+ shadow_root,
+ serde_json::from_str(raw_body)?,
+ )
+ }
+ Route::FindShadowRootElements => {
+ let shadow_id = try_opt!(
+ params.get("shadowId"),
+ ErrorStatus::InvalidArgument,
+ "Missing shadowId parameter"
+ );
+ let shadow_root = ShadowRoot(shadow_id.as_str().into());
+ WebDriverCommand::FindShadowRootElements(
+ shadow_root,
+ serde_json::from_str(raw_body)?,
+ )
+ }
+ Route::GetActiveElement => WebDriverCommand::GetActiveElement,
+ Route::GetShadowRoot => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetShadowRoot(element)
+ }
+ Route::GetComputedLabel => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetComputedLabel(element)
+ }
+ Route::GetComputedRole => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetComputedRole(element)
+ }
+ Route::IsDisplayed => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::IsDisplayed(element)
+ }
+ Route::IsSelected => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::IsSelected(element)
+ }
+ Route::GetElementAttribute => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ let attr = try_opt!(
+ params.get("name"),
+ ErrorStatus::InvalidArgument,
+ "Missing name parameter"
+ )
+ .as_str();
+ WebDriverCommand::GetElementAttribute(element, attr.into())
+ }
+ Route::GetElementProperty => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ let property = try_opt!(
+ params.get("name"),
+ ErrorStatus::InvalidArgument,
+ "Missing name parameter"
+ )
+ .as_str();
+ WebDriverCommand::GetElementProperty(element, property.into())
+ }
+ Route::GetCSSValue => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ let property = try_opt!(
+ params.get("propertyName"),
+ ErrorStatus::InvalidArgument,
+ "Missing propertyName parameter"
+ )
+ .as_str();
+ WebDriverCommand::GetCSSValue(element, property.into())
+ }
+ Route::GetElementText => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetElementText(element)
+ }
+ Route::GetElementTagName => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetElementTagName(element)
+ }
+ Route::GetElementRect => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::GetElementRect(element)
+ }
+ Route::IsEnabled => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::IsEnabled(element)
+ }
+ Route::ElementClick => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::ElementClick(element)
+ }
+ Route::ElementClear => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::ElementClear(element)
+ }
+ Route::ElementSendKeys => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::ElementSendKeys(element, serde_json::from_str(raw_body)?)
+ }
+ Route::ExecuteScript => {
+ WebDriverCommand::ExecuteScript(serde_json::from_str(raw_body)?)
+ }
+ Route::ExecuteAsyncScript => {
+ WebDriverCommand::ExecuteAsyncScript(serde_json::from_str(raw_body)?)
+ }
+ Route::GetCookies => WebDriverCommand::GetCookies,
+ Route::GetNamedCookie => {
+ let name = try_opt!(
+ params.get("name"),
+ ErrorStatus::InvalidArgument,
+ "Missing 'name' parameter"
+ )
+ .as_str()
+ .into();
+ WebDriverCommand::GetNamedCookie(name)
+ }
+ Route::AddCookie => WebDriverCommand::AddCookie(serde_json::from_str(raw_body)?),
+ Route::DeleteCookies => WebDriverCommand::DeleteCookies,
+ Route::DeleteCookie => {
+ let name = try_opt!(
+ params.get("name"),
+ ErrorStatus::InvalidArgument,
+ "Missing name parameter"
+ )
+ .as_str()
+ .into();
+ WebDriverCommand::DeleteCookie(name)
+ }
+ Route::PerformActions => {
+ WebDriverCommand::PerformActions(serde_json::from_str(raw_body)?)
+ }
+ Route::ReleaseActions => WebDriverCommand::ReleaseActions,
+ Route::DismissAlert => WebDriverCommand::DismissAlert,
+ Route::AcceptAlert => WebDriverCommand::AcceptAlert,
+ Route::GetAlertText => WebDriverCommand::GetAlertText,
+ Route::SendAlertText => {
+ WebDriverCommand::SendAlertText(serde_json::from_str(raw_body)?)
+ }
+ Route::TakeScreenshot => WebDriverCommand::TakeScreenshot,
+ Route::TakeElementScreenshot => {
+ let element_id = try_opt!(
+ params.get("elementId"),
+ ErrorStatus::InvalidArgument,
+ "Missing elementId parameter"
+ );
+ let element = WebElement(element_id.as_str().into());
+ WebDriverCommand::TakeElementScreenshot(element)
+ }
+ Route::Print => WebDriverCommand::Print(serde_json::from_str(raw_body)?),
+ Route::Status => WebDriverCommand::Status,
+ Route::Extension(ref extension) => extension.command(params, &body_data)?,
+ Route::WebAuthnAddVirtualAuthenticator => {
+ WebDriverCommand::WebAuthnAddVirtualAuthenticator(serde_json::from_str(raw_body)?)
+ }
+ Route::WebAuthnRemoveVirtualAuthenticator => {
+ WebDriverCommand::WebAuthnRemoveVirtualAuthenticator
+ }
+ Route::WebAuthnAddCredential => {
+ WebDriverCommand::WebAuthnAddCredential(serde_json::from_str(raw_body)?)
+ }
+ Route::WebAuthnGetCredentials => WebDriverCommand::WebAuthnGetCredentials,
+ Route::WebAuthnRemoveCredential => WebDriverCommand::WebAuthnRemoveCredential,
+ Route::WebAuthnRemoveAllCredentials => WebDriverCommand::WebAuthnRemoveAllCredentials,
+ Route::WebAuthnSetUserVerified => {
+ WebDriverCommand::WebAuthnSetUserVerified(serde_json::from_str(raw_body)?)
+ }
+ };
+ Ok(WebDriverMessage::new(session_id, command))
+ }
+
+ fn get_session_id(params: &Parameters) -> Option<String> {
+ params.get("sessionId").cloned()
+ }
+
+ fn decode_body(body: &str, requires_body: bool) -> WebDriverResult<Value> {
+ if requires_body {
+ match serde_json::from_str(body) {
+ Ok(x @ Value::Object(_)) => Ok(x),
+ Ok(_) => Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ "Body was not a JSON Object",
+ )),
+ Err(e) => {
+ if e.is_io() {
+ Err(WebDriverError::new(
+ ErrorStatus::InvalidArgument,
+ format!("I/O error whilst decoding body: {}", e),
+ ))
+ } else {
+ let msg = format!("Failed to decode request as JSON: {}", body);
+ let stack = format!("Syntax error at :{}:{}", e.line(), e.column());
+ Err(WebDriverError::new_with_stack(
+ ErrorStatus::InvalidArgument,
+ msg,
+ stack,
+ ))
+ }
+ }
+ }
+ } else {
+ Ok(Value::Null)
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct ActionsParameters {
+ pub actions: Vec<ActionSequence>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+#[serde(remote = "Self")]
+pub struct AddCookieParameters {
+ pub name: String,
+ pub value: String,
+ pub path: Option<String>,
+ pub domain: Option<String>,
+ #[serde(default)]
+ pub secure: bool,
+ #[serde(default)]
+ pub httpOnly: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub expiry: Option<Date>,
+ pub sameSite: Option<String>,
+}
+
+impl<'de> Deserialize<'de> for AddCookieParameters {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ struct Wrapper {
+ #[serde(with = "AddCookieParameters")]
+ cookie: AddCookieParameters,
+ }
+
+ Wrapper::deserialize(deserializer).map(|wrapper| wrapper.cookie)
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct GetParameters {
+ pub url: String,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct GetNamedCookieParameters {
+ pub name: Option<String>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct JavascriptCommandParameters {
+ pub script: String,
+ pub args: Option<Vec<Value>>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct LocatorParameters {
+ pub using: LocatorStrategy,
+ pub value: String,
+}
+
+/// Wrapper around the two supported variants of new session paramters.
+///
+/// The Spec variant is used for storing spec-compliant parameters whereas
+/// the legacy variant is used to store `desiredCapabilities`/`requiredCapabilities`
+/// parameters, and is intended to minimise breakage as we transition users to
+/// the spec design.
+#[derive(Debug, PartialEq)]
+pub enum NewSessionParameters {
+ Spec(SpecNewSessionParameters),
+ Legacy(LegacyNewSessionParameters),
+}
+
+impl<'de> Deserialize<'de> for NewSessionParameters {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = serde_json::Value::deserialize(deserializer)?;
+ if let Some(caps) = value.get("capabilities") {
+ if !caps.is_object() {
+ return Err(de::Error::custom("capabilities must be objects"));
+ }
+ let caps = SpecNewSessionParameters::deserialize(caps).map_err(de::Error::custom)?;
+ return Ok(NewSessionParameters::Spec(caps));
+ }
+
+ warn!("You are using deprecated legacy session negotiation patterns (desiredCapabilities/requiredCapabilities), see https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities#Legacy");
+ let legacy = LegacyNewSessionParameters::deserialize(value).map_err(de::Error::custom)?;
+ Ok(NewSessionParameters::Legacy(legacy))
+ }
+}
+
+impl CapabilitiesMatching for NewSessionParameters {
+ fn match_browser<T: BrowserCapabilities>(
+ &self,
+ browser_capabilities: &mut T,
+ ) -> WebDriverResult<Option<Capabilities>> {
+ match self {
+ NewSessionParameters::Spec(x) => x.match_browser(browser_capabilities),
+ NewSessionParameters::Legacy(x) => x.match_browser(browser_capabilities),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct NewWindowParameters {
+ #[serde(rename = "type")]
+ pub type_hint: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum PrintPageRange {
+ Integer(u64),
+ Range(String),
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct PrintParameters {
+ pub orientation: PrintOrientation,
+ #[serde(deserialize_with = "deserialize_to_print_scale_f64")]
+ pub scale: f64,
+ pub background: bool,
+ pub page: PrintPage,
+ pub margin: PrintMargins,
+ pub page_ranges: Vec<PrintPageRange>,
+ pub shrink_to_fit: bool,
+}
+
+impl Default for PrintParameters {
+ fn default() -> Self {
+ PrintParameters {
+ orientation: PrintOrientation::default(),
+ scale: 1.0,
+ background: false,
+ page: PrintPage::default(),
+ margin: PrintMargins::default(),
+ page_ranges: Vec::new(),
+ shrink_to_fit: true,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum PrintOrientation {
+ Landscape,
+ #[default]
+ Portrait,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct PrintPage {
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub width: f64,
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub height: f64,
+}
+
+impl Default for PrintPage {
+ fn default() -> Self {
+ PrintPage {
+ width: 21.59,
+ height: 27.94,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct PrintMargins {
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub top: f64,
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub bottom: f64,
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub left: f64,
+ #[serde(deserialize_with = "deserialize_to_positive_f64")]
+ pub right: f64,
+}
+
+impl Default for PrintMargins {
+ fn default() -> Self {
+ PrintMargins {
+ top: 1.0,
+ bottom: 1.0,
+ left: 1.0,
+ right: 1.0,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+pub enum WebAuthnProtocol {
+ #[serde(rename = "ctap1/u2f")]
+ Ctap1U2f,
+ #[serde(rename = "ctap2")]
+ Ctap2,
+ #[serde(rename = "ctap2_1")]
+ Ctap2_1,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+pub enum AuthenticatorTransport {
+ Usb,
+ Nfc,
+ Ble,
+ SmartCard,
+ Hybrid,
+ Internal,
+}
+
+fn default_as_true() -> bool {
+ true
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct AuthenticatorParameters {
+ pub protocol: WebAuthnProtocol,
+ pub transport: AuthenticatorTransport,
+ #[serde(default)]
+ pub has_resident_key: bool,
+ #[serde(default)]
+ pub has_user_verification: bool,
+ #[serde(default = "default_as_true")]
+ pub is_user_consenting: bool,
+ #[serde(default)]
+ pub is_user_verified: bool,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+pub struct UserVerificationParameters {
+ #[serde(rename = "isUserVerified")]
+ pub is_user_verified: bool,
+}
+
+fn deserialize_to_positive_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let val = f64::deserialize(deserializer)?;
+ if val < 0.0 {
+ return Err(de::Error::custom(format!("{} is negative", val)));
+ };
+ Ok(val)
+}
+
+fn deserialize_to_print_scale_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let val = f64::deserialize(deserializer)?;
+ if !(0.1..=2.0).contains(&val) {
+ return Err(de::Error::custom(format!("{} is outside range 0.1-2", val)));
+ };
+ Ok(val)
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct SendKeysParameters {
+ pub text: String,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct SwitchToFrameParameters {
+ pub id: FrameId,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct SwitchToWindowParameters {
+ pub handle: String,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct TakeScreenshotParameters {
+ pub element: Option<WebElement>,
+}
+
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct TimeoutsParameters {
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_u64"
+ )]
+ pub implicit: Option<u64>,
+ #[serde(
+ default,
+ rename = "pageLoad",
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_u64"
+ )]
+ pub page_load: Option<u64>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_nullable_u64"
+ )]
+ #[allow(clippy::option_option)]
+ pub script: Option<Option<u64>>,
+}
+
+#[allow(clippy::option_option)]
+fn deserialize_to_nullable_u64<'de, D>(deserializer: D) -> Result<Option<Option<u64>>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt: Option<f64> = Option::deserialize(deserializer)?;
+ let value = match opt {
+ Some(n) => {
+ if n < 0.0 || n.fract() != 0.0 {
+ return Err(de::Error::custom(format!(
+ "{} is not a positive Integer",
+ n
+ )));
+ }
+ if (n as u64) > MAX_SAFE_INTEGER {
+ return Err(de::Error::custom(format!(
+ "{} is greater than maximum safe integer",
+ n
+ )));
+ }
+ Some(Some(n as u64))
+ }
+ None => Some(None),
+ };
+
+ Ok(value)
+}
+
+fn deserialize_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt: Option<f64> = Option::deserialize(deserializer)?;
+ let value = match opt {
+ Some(n) => {
+ if n < 0.0 || n.fract() != 0.0 {
+ return Err(de::Error::custom(format!(
+ "{} is not a positive Integer",
+ n
+ )));
+ }
+ if (n as u64) > MAX_SAFE_INTEGER {
+ return Err(de::Error::custom(format!(
+ "{} is greater than maximum safe integer",
+ n
+ )));
+ }
+ Some(n as u64)
+ }
+ None => return Err(de::Error::custom("null is not a positive integer")),
+ };
+
+ Ok(value)
+}
+
+/// A top-level browsing context’s window rect is a dictionary of the
+/// [`screenX`], [`screenY`], `width`, and `height` attributes of the
+/// `WindowProxy`.
+///
+/// In some user agents the operating system’s window dimensions, including
+/// decorations, are provided by the proprietary `window.outerWidth` and
+/// `window.outerHeight` DOM properties.
+///
+/// [`screenX`]: https://w3c.github.io/webdriver/webdriver-spec.html#dfn-screenx
+/// [`screenY`]: https://w3c.github.io/webdriver/webdriver-spec.html#dfn-screeny
+#[derive(Debug, PartialEq, Serialize, Deserialize)]
+pub struct WindowRectParameters {
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_i32"
+ )]
+ pub x: Option<i32>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_i32"
+ )]
+ pub y: Option<i32>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_positive_i32"
+ )]
+ pub width: Option<i32>,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ deserialize_with = "deserialize_to_positive_i32"
+ )]
+ pub height: Option<i32>,
+}
+
+fn deserialize_to_i32<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt = Option::deserialize(deserializer)?.map(|value: f64| value as i64);
+ let value = match opt {
+ Some(n) => {
+ if n < i64::from(i32::min_value()) || n > i64::from(i32::max_value()) {
+ return Err(de::Error::custom(format!("'{}' is larger than i32", n)));
+ }
+ Some(n as i32)
+ }
+ None => None,
+ };
+
+ Ok(value)
+}
+
+fn deserialize_to_positive_i32<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt = Option::deserialize(deserializer)?.map(|value: f64| value as i64);
+ let value = match opt {
+ Some(n) => {
+ if n < 0 || n > i64::from(i32::max_value()) {
+ return Err(de::Error::custom(format!("'{}' is outside of i32", n)));
+ }
+ Some(n as i32)
+ }
+ None => None,
+ };
+
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::capabilities::SpecNewSessionParameters;
+ use crate::common::ELEMENT_KEY;
+ use crate::test::assert_de;
+ use serde_json::{self, json};
+
+ #[test]
+ fn test_json_actions_parameters_missing_actions_field() {
+ assert!(serde_json::from_value::<ActionsParameters>(json!({})).is_err());
+ }
+
+ #[test]
+ fn test_json_actions_parameters_invalid() {
+ assert!(serde_json::from_value::<ActionsParameters>(json!({ "actions": null })).is_err());
+ }
+
+ #[test]
+ fn test_json_action_parameters_empty_list() {
+ assert_de(
+ &ActionsParameters { actions: vec![] },
+ json!({"actions": []}),
+ );
+ }
+
+ #[test]
+ fn test_json_action_parameters_with_unknown_field() {
+ assert_de(
+ &ActionsParameters { actions: vec![] },
+ json!({"actions": [], "foo": "bar"}),
+ );
+ }
+
+ #[test]
+ fn test_json_add_cookie_parameters_with_values() {
+ let json = json!({"cookie": {
+ "name": "foo",
+ "value": "bar",
+ "path": "/",
+ "domain": "foo.bar",
+ "expiry": 123,
+ "secure": true,
+ "httpOnly": false,
+ "sameSite": "Lax",
+ }});
+ let cookie = AddCookieParameters {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: Some("/".into()),
+ domain: Some("foo.bar".into()),
+ expiry: Some(Date(123)),
+ secure: true,
+ httpOnly: false,
+ sameSite: Some("Lax".into()),
+ };
+
+ assert_de(&cookie, json);
+ }
+
+ #[test]
+ fn test_json_add_cookie_parameters_with_optional_null_fields() {
+ let json = json!({"cookie": {
+ "name": "foo",
+ "value": "bar",
+ "path": null,
+ "domain": null,
+ "expiry": null,
+ "secure": true,
+ "httpOnly": false,
+ "sameSite": null,
+ }});
+ let cookie = AddCookieParameters {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: None,
+ domain: None,
+ expiry: None,
+ secure: true,
+ httpOnly: false,
+ sameSite: None,
+ };
+
+ assert_de(&cookie, json);
+ }
+
+ #[test]
+ fn test_json_add_cookie_parameters_without_optional_fields() {
+ let json = json!({"cookie": {
+ "name": "foo",
+ "value": "bar",
+ "secure": true,
+ "httpOnly": false,
+ }});
+ let cookie = AddCookieParameters {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: None,
+ domain: None,
+ expiry: None,
+ secure: true,
+ httpOnly: false,
+ sameSite: None,
+ };
+
+ assert_de(&cookie, json);
+ }
+
+ #[test]
+ fn test_json_add_cookie_parameters_with_invalid_cookie_field() {
+ assert!(serde_json::from_value::<AddCookieParameters>(json!({"name": "foo"})).is_err());
+ }
+
+ #[test]
+ fn test_json_add_cookie_parameters_with_unknown_field() {
+ let json = json!({"cookie": {
+ "name": "foo",
+ "value": "bar",
+ "secure": true,
+ "httpOnly": false,
+ "foo": "bar",
+ }, "baz": "bah"});
+ let cookie = AddCookieParameters {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: None,
+ domain: None,
+ expiry: None,
+ secure: true,
+ httpOnly: false,
+ sameSite: None,
+ };
+
+ assert_de(&cookie, json);
+ }
+
+ #[test]
+ fn test_json_get_parameters_with_url() {
+ assert_de(
+ &GetParameters {
+ url: "foo.bar".into(),
+ },
+ json!({"url": "foo.bar"}),
+ );
+ }
+
+ #[test]
+ fn test_json_get_parameters_with_invalid_url_value() {
+ assert!(serde_json::from_value::<GetParameters>(json!({"url": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_get_parameters_with_invalid_url_field() {
+ assert!(serde_json::from_value::<GetParameters>(json!({"foo": "bar"})).is_err());
+ }
+
+ #[test]
+ fn test_json_get_parameters_with_unknown_field() {
+ assert_de(
+ &GetParameters {
+ url: "foo.bar".into(),
+ },
+ json!({"url": "foo.bar", "foo": "bar"}),
+ );
+ }
+
+ #[test]
+ fn test_json_get_named_cookie_parameters_with_value() {
+ assert_de(
+ &GetNamedCookieParameters {
+ name: Some("foo".into()),
+ },
+ json!({"name": "foo"}),
+ );
+ }
+
+ #[test]
+ fn test_json_get_named_cookie_parameters_with_optional_null_field() {
+ assert_de(
+ &GetNamedCookieParameters { name: None },
+ json!({ "name": null }),
+ );
+ }
+
+ #[test]
+ fn test_json_get_named_cookie_parameters_without_optional_null_field() {
+ assert_de(&GetNamedCookieParameters { name: None }, json!({}));
+ }
+
+ #[test]
+ fn test_json_get_named_cookie_parameters_with_invalid_name_field() {
+ assert!(serde_json::from_value::<GetNamedCookieParameters>(json!({"name": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_get_named_cookie_parameters_with_unknown_field() {
+ assert_de(
+ &GetNamedCookieParameters {
+ name: Some("foo".into()),
+ },
+ json!({"name": "foo", "foo": "bar"}),
+ );
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_with_values() {
+ let json = json!({
+ "script": "foo",
+ "args": ["1", 2],
+ });
+ let execute_script = JavascriptCommandParameters {
+ script: "foo".into(),
+ args: Some(vec!["1".into(), 2.into()]),
+ };
+
+ assert_de(&execute_script, json);
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_with_optional_null_field() {
+ let json = json!({
+ "script": "foo",
+ "args": null,
+ });
+ let execute_script = JavascriptCommandParameters {
+ script: "foo".into(),
+ args: None,
+ };
+
+ assert_de(&execute_script, json);
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_without_optional_null_field() {
+ let execute_script = JavascriptCommandParameters {
+ script: "foo".into(),
+ args: None,
+ };
+ assert_de(&execute_script, json!({"script": "foo"}));
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_invalid_script_field() {
+ let json = json!({ "script": null });
+ assert!(serde_json::from_value::<JavascriptCommandParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_invalid_args_field() {
+ let json = json!({
+ "script": null,
+ "args": "1",
+ });
+ assert!(serde_json::from_value::<JavascriptCommandParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_missing_script_field() {
+ let json = json!({ "args": null });
+ assert!(serde_json::from_value::<JavascriptCommandParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_javascript_command_parameters_with_unknown_field() {
+ let json = json!({
+ "script": "foo",
+ "foo": "bar",
+ });
+ let execute_script = JavascriptCommandParameters {
+ script: "foo".into(),
+ args: None,
+ };
+
+ assert_de(&execute_script, json);
+ }
+
+ #[test]
+ fn test_json_locator_parameters_with_values() {
+ let json = json!({
+ "using": "xpath",
+ "value": "bar",
+ });
+ let locator = LocatorParameters {
+ using: LocatorStrategy::XPath,
+ value: "bar".into(),
+ };
+
+ assert_de(&locator, json);
+ }
+
+ #[test]
+ fn test_json_locator_parameters_invalid_using_field() {
+ let json = json!({
+ "using": "foo",
+ "value": "bar",
+ });
+ assert!(serde_json::from_value::<LocatorParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_locator_parameters_invalid_value_field() {
+ let json = json!({
+ "using": "xpath",
+ "value": 3,
+ });
+ assert!(serde_json::from_value::<LocatorParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_locator_parameters_missing_using_field() {
+ assert!(serde_json::from_value::<LocatorParameters>(json!({"value": "bar"})).is_err());
+ }
+
+ #[test]
+ fn test_json_locator_parameters_missing_value_field() {
+ assert!(serde_json::from_value::<LocatorParameters>(json!({"using": "xpath"})).is_err());
+ }
+
+ #[test]
+ fn test_json_locator_parameters_with_unknown_field() {
+ let json = json!({
+ "using": "xpath",
+ "value": "bar",
+ "foo": "bar",
+ });
+ let locator = LocatorParameters {
+ using: LocatorStrategy::XPath,
+ value: "bar".into(),
+ };
+
+ assert_de(&locator, json);
+ }
+
+ #[test]
+ fn test_json_new_session_parameters_spec() {
+ let json = json!({"capabilities": {
+ "alwaysMatch": {},
+ "firstMatch": [{}],
+ }});
+ let caps = NewSessionParameters::Spec(SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ });
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_new_session_parameters_capabilities_null() {
+ let json = json!({ "capabilities": null });
+ assert!(serde_json::from_value::<NewSessionParameters>(json).is_err());
+ }
+
+ #[test]
+ fn test_json_new_session_parameters_legacy() {
+ let json = json!({
+ "desiredCapabilities": {},
+ "requiredCapabilities": {},
+ });
+ let caps = NewSessionParameters::Legacy(LegacyNewSessionParameters {
+ desired: Capabilities::new(),
+ required: Capabilities::new(),
+ });
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_new_session_parameters_spec_and_legacy() {
+ let json = json!({
+ "capabilities": {
+ "alwaysMatch": {},
+ "firstMatch": [{}],
+ },
+ "desiredCapabilities": {},
+ "requiredCapabilities": {},
+ });
+ let caps = NewSessionParameters::Spec(SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ });
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_new_session_parameters_with_unknown_field() {
+ let json = json!({
+ "capabilities": {
+ "alwaysMatch": {},
+ "firstMatch": [{}]
+ },
+ "foo": "bar",
+ });
+ let caps = NewSessionParameters::Spec(SpecNewSessionParameters {
+ alwaysMatch: Capabilities::new(),
+ firstMatch: vec![Capabilities::new()],
+ });
+
+ assert_de(&caps, json);
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_without_type() {
+ assert_de(&NewWindowParameters { type_hint: None }, json!({}));
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_with_optional_null_type() {
+ assert_de(
+ &NewWindowParameters { type_hint: None },
+ json!({ "type": null }),
+ );
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_with_supported_type() {
+ assert_de(
+ &NewWindowParameters {
+ type_hint: Some("tab".into()),
+ },
+ json!({"type": "tab"}),
+ );
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_with_unknown_type() {
+ assert_de(
+ &NewWindowParameters {
+ type_hint: Some("foo".into()),
+ },
+ json!({"type": "foo"}),
+ );
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_with_invalid_type() {
+ assert!(serde_json::from_value::<NewWindowParameters>(json!({"type": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_new_window_parameters_with_unknown_field() {
+ let json = json!({
+ "type": "tab",
+ "foo": "bar",
+ });
+ let new_window = NewWindowParameters {
+ type_hint: Some("tab".into()),
+ };
+
+ assert_de(&new_window, json);
+ }
+
+ #[test]
+ fn test_json_print_defaults() {
+ let params = PrintParameters::default();
+ assert_de(&params, json!({}));
+ }
+
+ #[test]
+ fn test_json_print() {
+ let params = PrintParameters {
+ orientation: PrintOrientation::Landscape,
+ page: PrintPage {
+ width: 10.0,
+ ..Default::default()
+ },
+ margin: PrintMargins {
+ top: 10.0,
+ ..Default::default()
+ },
+ scale: 1.5,
+ ..Default::default()
+ };
+ assert_de(
+ &params,
+ json!({"orientation": "landscape", "page": {"width": 10}, "margin": {"top": 10}, "scale": 1.5}),
+ );
+ }
+
+ #[test]
+ fn test_json_scale_invalid() {
+ assert!(serde_json::from_value::<PrintParameters>(json!({"scale": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_authenticator() {
+ let params = AuthenticatorParameters {
+ protocol: WebAuthnProtocol::Ctap1U2f,
+ transport: AuthenticatorTransport::Usb,
+ has_resident_key: false,
+ has_user_verification: false,
+ is_user_consenting: false,
+ is_user_verified: false,
+ };
+ assert_de(
+ &params,
+ json!({"protocol": "ctap1/u2f", "transport": "usb", "hasResidentKey": false, "hasUserVerification": false, "isUserConsenting": false, "isUserVerified": false}),
+ );
+ }
+
+ #[test]
+ fn test_json_credential() {
+ use base64::{engine::general_purpose::URL_SAFE, Engine};
+
+ let encoded_string = URL_SAFE.encode(b"hello internet~");
+ let params = CredentialParameters {
+ credential_id: r"c3VwZXIgcmVhZGVy".to_string(),
+ is_resident_credential: true,
+ rp_id: "valid.rpid".to_string(),
+ private_key: encoded_string.clone(),
+ user_handle: encoded_string.clone(),
+ sign_count: 0,
+ };
+ assert_de(
+ &params,
+ json!({"credentialId": r"c3VwZXIgcmVhZGVy", "isResidentCredential": true, "rpId": "valid.rpid", "privateKey": encoded_string, "userHandle": encoded_string, "signCount": 0}),
+ );
+ }
+
+ #[test]
+ fn test_json_user_verification() {
+ let params = UserVerificationParameters {
+ is_user_verified: false,
+ };
+ assert_de(&params, json!({"isUserVerified": false}));
+ }
+
+ #[test]
+ fn test_json_send_keys_parameters_with_value() {
+ assert_de(
+ &SendKeysParameters { text: "foo".into() },
+ json!({"text": "foo"}),
+ );
+ }
+
+ #[test]
+ fn test_json_send_keys_parameters_invalid_text_field() {
+ assert!(serde_json::from_value::<SendKeysParameters>(json!({"text": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_send_keys_parameters_missing_text_field() {
+ assert!(serde_json::from_value::<SendKeysParameters>(json!({})).is_err());
+ }
+
+ #[test]
+ fn test_json_send_keys_parameters_with_unknown_field() {
+ let json = json!({
+ "text": "foo",
+ "foo": "bar",
+ });
+ let send_keys = SendKeysParameters { text: "foo".into() };
+
+ assert_de(&send_keys, json);
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_number() {
+ assert_de(
+ &SwitchToFrameParameters {
+ id: FrameId::Short(3),
+ },
+ json!({"id": 3}),
+ );
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_null() {
+ assert_de(
+ &SwitchToFrameParameters { id: FrameId::Top },
+ json!({"id": null}),
+ );
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_web_element() {
+ assert_de(
+ &SwitchToFrameParameters {
+ id: FrameId::Element(WebElement("foo".to_string())),
+ },
+ json!({"id": {"element-6066-11e4-a52e-4f735466cecf": "foo"}}),
+ );
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_missing_id() {
+ assert!(serde_json::from_value::<SwitchToFrameParameters>(json!({})).is_err())
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_invalid_id_field() {
+ assert!(serde_json::from_value::<SwitchToFrameParameters>(json!({"id": "3"})).is_err());
+ }
+
+ #[test]
+ fn test_json_switch_to_frame_parameters_with_unknown_field() {
+ let json = json!({
+ "id":3,
+ "foo": "bar",
+ });
+ let switch_to_frame = SwitchToFrameParameters {
+ id: FrameId::Short(3),
+ };
+
+ assert_de(&switch_to_frame, json);
+ }
+
+ #[test]
+ fn test_json_switch_to_window_parameters_with_value() {
+ assert_de(
+ &SwitchToWindowParameters {
+ handle: "foo".into(),
+ },
+ json!({"handle": "foo"}),
+ );
+ }
+
+ #[test]
+ fn test_json_switch_to_window_parameters_invalid_handle_field() {
+ assert!(serde_json::from_value::<SwitchToWindowParameters>(json!({"handle": 3})).is_err());
+ }
+
+ #[test]
+ fn test_json_switch_to_window_parameters_missing_handle_field() {
+ assert!(serde_json::from_value::<SwitchToWindowParameters>(json!({})).is_err());
+ }
+
+ #[test]
+ fn test_json_switch_to_window_parameters_with_unknown_field() {
+ let json = json!({
+ "handle": "foo",
+ "foo": "bar",
+ });
+ let switch_to_window = SwitchToWindowParameters {
+ handle: "foo".into(),
+ };
+
+ assert_de(&switch_to_window, json);
+ }
+
+ #[test]
+ fn test_json_take_screenshot_parameters_with_element() {
+ assert_de(
+ &TakeScreenshotParameters {
+ element: Some(WebElement("elem".into())),
+ },
+ json!({"element": {ELEMENT_KEY: "elem"}}),
+ );
+ }
+
+ #[test]
+ fn test_json_take_screenshot_parameters_with_optional_null_field() {
+ assert_de(
+ &TakeScreenshotParameters { element: None },
+ json!({ "element": null }),
+ );
+ }
+
+ #[test]
+ fn test_json_take_screenshot_parameters_without_optional_null_field() {
+ assert_de(&TakeScreenshotParameters { element: None }, json!({}));
+ }
+
+ #[test]
+ fn test_json_take_screenshot_parameters_with_invalid_element_field() {
+ assert!(
+ serde_json::from_value::<TakeScreenshotParameters>(json!({"element": "foo"})).is_err()
+ );
+ }
+
+ #[test]
+ fn test_json_take_screenshot_parameters_with_unknown_field() {
+ let json = json!({
+ "element": {ELEMENT_KEY: "elem"},
+ "foo": "bar",
+ });
+ let take_screenshot = TakeScreenshotParameters {
+ element: Some(WebElement("elem".into())),
+ };
+
+ assert_de(&take_screenshot, json);
+ }
+
+ #[test]
+ fn test_json_timeout_parameters_with_only_null_script_timeout() {
+ let timeouts = TimeoutsParameters {
+ implicit: None,
+ page_load: None,
+ script: Some(None),
+ };
+ assert_de(&timeouts, json!({ "script": null }));
+ }
+
+ #[test]
+ fn test_json_timeout_parameters_with_only_null_implicit_timeout() {
+ assert!(serde_json::from_value::<TimeoutsParameters>(json!({ "implicit": null })).is_err());
+ }
+
+ #[test]
+ fn test_json_timeout_parameters_with_only_null_pageload_timeout() {
+ assert!(serde_json::from_value::<TimeoutsParameters>(json!({ "pageLoad": null })).is_err());
+ }
+
+ #[test]
+ fn test_json_timeout_parameters_without_optional_null_field() {
+ let timeouts = TimeoutsParameters {
+ implicit: None,
+ page_load: None,
+ script: None,
+ };
+ assert_de(&timeouts, json!({}));
+ }
+
+ #[test]
+ fn test_json_timeout_parameters_with_unknown_field() {
+ let json = json!({
+ "script": 60000,
+ "foo": "bar",
+ });
+ let timeouts = TimeoutsParameters {
+ implicit: None,
+ page_load: None,
+ script: Some(Some(60000)),
+ };
+
+ assert_de(&timeouts, json);
+ }
+
+ #[test]
+ fn test_json_window_rect_parameters_with_values() {
+ let json = json!({
+ "x": 0,
+ "y": 1,
+ "width": 2,
+ "height": 3,
+ });
+ let rect = WindowRectParameters {
+ x: Some(0i32),
+ y: Some(1i32),
+ width: Some(2i32),
+ height: Some(3i32),
+ };
+
+ assert_de(&rect, json);
+ }
+
+ #[test]
+ fn test_json_window_rect_parameters_with_optional_null_fields() {
+ let json = json!({
+ "x": null,
+ "y": null,
+ "width": null,
+ "height": null,
+ });
+ let rect = WindowRectParameters {
+ x: None,
+ y: None,
+ width: None,
+ height: None,
+ };
+
+ assert_de(&rect, json);
+ }
+
+ #[test]
+ fn test_json_window_rect_parameters_without_optional_fields() {
+ let rect = WindowRectParameters {
+ x: None,
+ y: None,
+ width: None,
+ height: None,
+ };
+ assert_de(&rect, json!({}));
+ }
+
+ #[test]
+ fn test_json_window_rect_parameters_invalid_values_float() {
+ let json = json!({
+ "x": 1.1,
+ "y": 2.2,
+ "width": 3.3,
+ "height": 4.4,
+ });
+ let rect = WindowRectParameters {
+ x: Some(1),
+ y: Some(2),
+ width: Some(3),
+ height: Some(4),
+ };
+
+ assert_de(&rect, json);
+ }
+
+ #[test]
+ fn test_json_window_rect_parameters_with_unknown_field() {
+ let json = json!({
+ "x": 1.1,
+ "y": 2.2,
+ "foo": "bar",
+ });
+ let rect = WindowRectParameters {
+ x: Some(1),
+ y: Some(2),
+ width: None,
+ height: None,
+ };
+
+ assert_de(&rect, json);
+ }
+}
diff --git a/testing/webdriver/src/common.rs b/testing/webdriver/src/common.rs
new file mode 100644
index 0000000000..496532fbbc
--- /dev/null
+++ b/testing/webdriver/src/common.rs
@@ -0,0 +1,319 @@
+/* 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::ser::{Serialize, Serializer};
+use serde::{Deserialize, Deserializer};
+use std::collections::HashMap;
+use std::fmt::{self, Display, Formatter};
+
+pub static ELEMENT_KEY: &str = "element-6066-11e4-a52e-4f735466cecf";
+pub static FRAME_KEY: &str = "frame-075b-4da1-b6ba-e579c2d3230a";
+pub static SHADOW_KEY: &str = "shadow-6066-11e4-a52e-4f735466cecf";
+pub static WINDOW_KEY: &str = "window-fcc6-11e5-b4f8-330a88ab9d7f";
+
+pub static MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
+
+pub type Parameters = HashMap<String, String>;
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Cookie {
+ pub name: String,
+ pub value: String,
+ pub path: Option<String>,
+ pub domain: Option<String>,
+ #[serde(default)]
+ pub secure: bool,
+ #[serde(rename = "httpOnly")]
+ pub http_only: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub expiry: Option<Date>,
+ #[serde(skip_serializing_if = "Option::is_none", rename = "sameSite")]
+ pub same_site: Option<String>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+pub struct CredentialParameters {
+ #[serde(rename = "credentialId")]
+ pub credential_id: String,
+ #[serde(rename = "isResidentCredential")]
+ pub is_resident_credential: bool,
+ #[serde(rename = "rpId")]
+ pub rp_id: String,
+ #[serde(rename = "privateKey")]
+ pub private_key: String,
+ #[serde(rename = "userHandle")]
+ #[serde(default)]
+ pub user_handle: String,
+ #[serde(rename = "signCount")]
+ pub sign_count: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Date(pub u64);
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum FrameId {
+ Short(u16),
+ #[serde(
+ rename = "element-6066-11e4-a52e-4f735466cecf",
+ serialize_with = "serialize_webelement_id"
+ )]
+ Element(WebElement),
+ Top,
+}
+
+// TODO(Henrik): Remove when ToMarionette trait has been fixed (Bug 1481776)
+fn serialize_webelement_id<S>(element: &WebElement, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ element.serialize(serializer)
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum LocatorStrategy {
+ #[serde(rename = "css selector")]
+ CSSSelector,
+ #[serde(rename = "link text")]
+ LinkText,
+ #[serde(rename = "partial link text")]
+ PartialLinkText,
+ #[serde(rename = "tag name")]
+ TagName,
+ #[serde(rename = "xpath")]
+ XPath,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowRoot(pub String);
+
+// private
+#[derive(Serialize, Deserialize)]
+struct ShadowRootObject {
+ #[serde(rename = "shadow-6066-11e4-a52e-4f735466cecf")]
+ id: String,
+}
+
+impl Serialize for ShadowRoot {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ ShadowRootObject { id: self.0.clone() }.serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for ShadowRoot {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Deserialize::deserialize(deserializer).map(|ShadowRootObject { id }| ShadowRoot(id))
+ }
+}
+
+impl Display for ShadowRoot {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct WebElement(pub String);
+
+// private
+#[derive(Serialize, Deserialize)]
+struct WebElementObject {
+ #[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
+ id: String,
+}
+
+impl Serialize for WebElement {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ WebElementObject { id: self.0.clone() }.serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for WebElement {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Deserialize::deserialize(deserializer).map(|WebElementObject { id }| WebElement(id))
+ }
+}
+
+impl Display for WebElement {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct WebFrame(pub String);
+
+// private
+#[derive(Serialize, Deserialize)]
+struct WebFrameObject {
+ #[serde(rename = "frame-075b-4da1-b6ba-e579c2d3230a")]
+ id: String,
+}
+
+impl Serialize for WebFrame {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ WebFrameObject { id: self.0.clone() }.serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for WebFrame {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Deserialize::deserialize(deserializer).map(|WebFrameObject { id }| WebFrame(id))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct WebWindow(pub String);
+
+// private
+#[derive(Serialize, Deserialize)]
+struct WebWindowObject {
+ #[serde(rename = "window-fcc6-11e5-b4f8-330a88ab9d7f")]
+ id: String,
+}
+
+impl Serialize for WebWindow {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ WebWindowObject { id: self.0.clone() }.serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for WebWindow {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Deserialize::deserialize(deserializer).map(|WebWindowObject { id }| WebWindow(id))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test::{assert_ser, assert_ser_de};
+ use serde_json::{self, json};
+
+ #[test]
+ fn test_json_date() {
+ assert_ser_de(&Date(1234), json!(1234));
+ }
+
+ #[test]
+ fn test_json_date_invalid() {
+ assert!(serde_json::from_value::<Date>(json!("2018-01-01")).is_err());
+ }
+
+ #[test]
+ fn test_json_frame_id_short() {
+ assert_ser_de(&FrameId::Short(1234), json!(1234));
+ }
+
+ #[test]
+ fn test_json_frame_id_webelement() {
+ assert_ser(
+ &FrameId::Element(WebElement("elem".into())),
+ json!({ELEMENT_KEY: "elem"}),
+ );
+ }
+
+ #[test]
+ fn test_json_frame_id_invalid() {
+ assert!(serde_json::from_value::<FrameId>(json!(true)).is_err());
+ }
+
+ #[test]
+ fn test_json_locator_strategy_css_selector() {
+ assert_ser_de(&LocatorStrategy::CSSSelector, json!("css selector"));
+ }
+
+ #[test]
+ fn test_json_locator_strategy_link_text() {
+ assert_ser_de(&LocatorStrategy::LinkText, json!("link text"));
+ }
+
+ #[test]
+ fn test_json_locator_strategy_partial_link_text() {
+ assert_ser_de(
+ &LocatorStrategy::PartialLinkText,
+ json!("partial link text"),
+ );
+ }
+
+ #[test]
+ fn test_json_locator_strategy_tag_name() {
+ assert_ser_de(&LocatorStrategy::TagName, json!("tag name"));
+ }
+
+ #[test]
+ fn test_json_locator_strategy_xpath() {
+ assert_ser_de(&LocatorStrategy::XPath, json!("xpath"));
+ }
+
+ #[test]
+ fn test_json_locator_strategy_invalid() {
+ assert!(serde_json::from_value::<LocatorStrategy>(json!("foo")).is_err());
+ }
+
+ #[test]
+ fn test_json_shadowroot() {
+ assert_ser_de(&ShadowRoot("shadow".into()), json!({SHADOW_KEY: "shadow"}));
+ }
+
+ #[test]
+ fn test_json_shadowroot_invalid() {
+ assert!(serde_json::from_value::<ShadowRoot>(json!({"invalid":"shadow"})).is_err());
+ }
+
+ #[test]
+ fn test_json_webelement() {
+ assert_ser_de(&WebElement("elem".into()), json!({ELEMENT_KEY: "elem"}));
+ }
+
+ #[test]
+ fn test_json_webelement_invalid() {
+ assert!(serde_json::from_value::<WebElement>(json!({"invalid": "elem"})).is_err());
+ }
+
+ #[test]
+ fn test_json_webframe() {
+ assert_ser_de(&WebFrame("frame".into()), json!({FRAME_KEY: "frame"}));
+ }
+
+ #[test]
+ fn test_json_webframe_invalid() {
+ assert!(serde_json::from_value::<WebFrame>(json!({"invalid": "frame"})).is_err());
+ }
+
+ #[test]
+ fn test_json_webwindow() {
+ assert_ser_de(&WebWindow("window".into()), json!({WINDOW_KEY: "window"}));
+ }
+
+ #[test]
+ fn test_json_webwindow_invalid() {
+ assert!(serde_json::from_value::<WebWindow>(json!({"invalid": "window"})).is_err());
+ }
+}
diff --git a/testing/webdriver/src/error.rs b/testing/webdriver/src/error.rs
new file mode 100644
index 0000000000..d4032a70db
--- /dev/null
+++ b/testing/webdriver/src/error.rs
@@ -0,0 +1,405 @@
+/* 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 base64::DecodeError;
+use http::StatusCode;
+use serde::de::{Deserialize, Deserializer};
+use serde::ser::{Serialize, Serializer};
+use std::borrow::Cow;
+use std::convert::From;
+use std::error;
+use std::io;
+use thiserror::Error;
+
+#[derive(Debug, PartialEq)]
+pub enum ErrorStatus {
+ /// The [element]'s [ShadowRoot] is not attached to the active document,
+ /// or the reference is stale
+ /// [element]: ../common/struct.WebElement.html
+ /// [ShadowRoot]: ../common/struct.ShadowRoot.html
+ DetachedShadowRoot,
+
+ /// The [`ElementClick`] command could not be completed because the
+ /// [element] receiving the events is obscuring the element that was
+ /// requested clicked.
+ ///
+ /// [`ElementClick`]:
+ /// ../command/enum.WebDriverCommand.html#variant.ElementClick
+ /// [element]: ../common/struct.WebElement.html
+ ElementClickIntercepted,
+
+ /// A [command] could not be completed because the element is not pointer-
+ /// or keyboard interactable.
+ ///
+ /// [command]: ../command/index.html
+ ElementNotInteractable,
+
+ /// An attempt was made to select an [element] that cannot be selected.
+ ///
+ /// [element]: ../common/struct.WebElement.html
+ ElementNotSelectable,
+
+ /// Navigation caused the user agent to hit a certificate warning, which is
+ /// usually the result of an expired or invalid TLS certificate.
+ InsecureCertificate,
+
+ /// The arguments passed to a [command] are either invalid or malformed.
+ ///
+ /// [command]: ../command/index.html
+ InvalidArgument,
+
+ /// An illegal attempt was made to set a cookie under a different domain
+ /// than the current page.
+ InvalidCookieDomain,
+
+ /// The coordinates provided to an interactions operation are invalid.
+ InvalidCoordinates,
+
+ /// A [command] could not be completed because the element is an invalid
+ /// state, e.g. attempting to click an element that is no longer attached
+ /// to the document.
+ ///
+ /// [command]: ../command/index.html
+ InvalidElementState,
+
+ /// Argument was an invalid selector.
+ InvalidSelector,
+
+ /// Occurs if the given session ID is not in the list of active sessions,
+ /// meaning the session either does not exist or that it’s not active.
+ InvalidSessionId,
+
+ /// An error occurred while executing JavaScript supplied by the user.
+ JavascriptError,
+
+ /// The target for mouse interaction is not in the browser’s viewport and
+ /// cannot be brought into that viewport.
+ MoveTargetOutOfBounds,
+
+ /// An attempt was made to operate on a modal dialogue when one was not
+ /// open.
+ NoSuchAlert,
+
+ /// No cookie matching the given path name was found amongst the associated
+ /// cookies of the current browsing context’s active document.
+ NoSuchCookie,
+
+ /// An [element] could not be located on the page using the given search
+ /// parameters.
+ ///
+ /// [element]: ../common/struct.WebElement.html
+ NoSuchElement,
+
+ /// A [command] to switch to a frame could not be satisfied because the
+ /// frame could not be found.
+ ///
+ /// [command]: ../command/index.html
+ NoSuchFrame,
+
+ /// An [element]'s [ShadowRoot] was not found attached to the element.
+ ///
+ /// [element]: ../common/struct.WebElement.html
+ /// [ShadowRoot]: ../common/struct.ShadowRoot.html
+ NoSuchShadowRoot,
+
+ /// A [command] to switch to a window could not be satisfied because the
+ /// window could not be found.
+ ///
+ /// [command]: ../command/index.html
+ NoSuchWindow,
+
+ /// A script did not complete before its timeout expired.
+ ScriptTimeout,
+
+ /// A new session could not be created.
+ SessionNotCreated,
+
+ /// A [command] failed because the referenced [element] is no longer
+ /// attached to the DOM.
+ ///
+ /// [command]: ../command/index.html
+ /// [element]: ../common/struct.WebElement.html
+ StaleElementReference,
+
+ /// An operation did not complete before its timeout expired.
+ Timeout,
+
+ /// A screen capture was made impossible.
+ UnableToCaptureScreen,
+
+ /// Setting the cookie’s value could not be done.
+ UnableToSetCookie,
+
+ /// A modal dialogue was open, blocking this operation.
+ UnexpectedAlertOpen,
+
+ /// The requested command could not be executed because it does not exist.
+ UnknownCommand,
+
+ /// An unknown error occurred in the remote end whilst processing the
+ /// [command].
+ ///
+ /// [command]: ../command/index.html
+ UnknownError,
+
+ /// The requested [command] matched a known endpoint, but did not match a
+ /// method for that endpoint.
+ ///
+ /// [command]: ../command/index.html
+ UnknownMethod,
+
+ /// Indicates that a [command] that should have executed properly is not
+ /// currently supported.
+ UnsupportedOperation,
+}
+
+impl Serialize for ErrorStatus {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.error_code().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for ErrorStatus {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let error_string = String::deserialize(deserializer)?;
+ Ok(ErrorStatus::from(error_string))
+ }
+}
+
+impl ErrorStatus {
+ /// Returns the string serialisation of the error type.
+ pub fn error_code(&self) -> &'static str {
+ use self::ErrorStatus::*;
+ match *self {
+ DetachedShadowRoot => "detached shadow root",
+ ElementClickIntercepted => "element click intercepted",
+ ElementNotInteractable => "element not interactable",
+ ElementNotSelectable => "element not selectable",
+ InsecureCertificate => "insecure certificate",
+ InvalidArgument => "invalid argument",
+ InvalidCookieDomain => "invalid cookie domain",
+ InvalidCoordinates => "invalid coordinates",
+ InvalidElementState => "invalid element state",
+ InvalidSelector => "invalid selector",
+ InvalidSessionId => "invalid session id",
+ JavascriptError => "javascript error",
+ MoveTargetOutOfBounds => "move target out of bounds",
+ NoSuchAlert => "no such alert",
+ NoSuchCookie => "no such cookie",
+ NoSuchElement => "no such element",
+ NoSuchFrame => "no such frame",
+ NoSuchShadowRoot => "no such shadow root",
+ NoSuchWindow => "no such window",
+ ScriptTimeout => "script timeout",
+ SessionNotCreated => "session not created",
+ StaleElementReference => "stale element reference",
+ Timeout => "timeout",
+ UnableToCaptureScreen => "unable to capture screen",
+ UnableToSetCookie => "unable to set cookie",
+ UnexpectedAlertOpen => "unexpected alert open",
+ UnknownError => "unknown error",
+ UnknownMethod => "unknown method",
+ UnknownCommand => "unknown command",
+ UnsupportedOperation => "unsupported operation",
+ }
+ }
+
+ /// Returns the correct HTTP status code associated with the error type.
+ pub fn http_status(&self) -> StatusCode {
+ use self::ErrorStatus::*;
+ match *self {
+ DetachedShadowRoot => StatusCode::NOT_FOUND,
+ ElementClickIntercepted => StatusCode::BAD_REQUEST,
+ ElementNotInteractable => StatusCode::BAD_REQUEST,
+ ElementNotSelectable => StatusCode::BAD_REQUEST,
+ InsecureCertificate => StatusCode::BAD_REQUEST,
+ InvalidArgument => StatusCode::BAD_REQUEST,
+ InvalidCookieDomain => StatusCode::BAD_REQUEST,
+ InvalidCoordinates => StatusCode::BAD_REQUEST,
+ InvalidElementState => StatusCode::BAD_REQUEST,
+ InvalidSelector => StatusCode::BAD_REQUEST,
+ InvalidSessionId => StatusCode::NOT_FOUND,
+ JavascriptError => StatusCode::INTERNAL_SERVER_ERROR,
+ MoveTargetOutOfBounds => StatusCode::INTERNAL_SERVER_ERROR,
+ NoSuchAlert => StatusCode::NOT_FOUND,
+ NoSuchCookie => StatusCode::NOT_FOUND,
+ NoSuchElement => StatusCode::NOT_FOUND,
+ NoSuchFrame => StatusCode::NOT_FOUND,
+ NoSuchShadowRoot => StatusCode::NOT_FOUND,
+ NoSuchWindow => StatusCode::NOT_FOUND,
+ ScriptTimeout => StatusCode::INTERNAL_SERVER_ERROR,
+ SessionNotCreated => StatusCode::INTERNAL_SERVER_ERROR,
+ StaleElementReference => StatusCode::NOT_FOUND,
+ Timeout => StatusCode::INTERNAL_SERVER_ERROR,
+ UnableToCaptureScreen => StatusCode::BAD_REQUEST,
+ UnableToSetCookie => StatusCode::INTERNAL_SERVER_ERROR,
+ UnexpectedAlertOpen => StatusCode::INTERNAL_SERVER_ERROR,
+ UnknownCommand => StatusCode::NOT_FOUND,
+ UnknownError => StatusCode::INTERNAL_SERVER_ERROR,
+ UnknownMethod => StatusCode::METHOD_NOT_ALLOWED,
+ UnsupportedOperation => StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ }
+}
+
+/// Deserialises error type from string.
+impl From<String> for ErrorStatus {
+ fn from(s: String) -> ErrorStatus {
+ use self::ErrorStatus::*;
+ match &*s {
+ "detached shadow root" => DetachedShadowRoot,
+ "element click intercepted" => ElementClickIntercepted,
+ "element not interactable" | "element not visible" => ElementNotInteractable,
+ "element not selectable" => ElementNotSelectable,
+ "insecure certificate" => InsecureCertificate,
+ "invalid argument" => InvalidArgument,
+ "invalid cookie domain" => InvalidCookieDomain,
+ "invalid coordinates" | "invalid element coordinates" => InvalidCoordinates,
+ "invalid element state" => InvalidElementState,
+ "invalid selector" => InvalidSelector,
+ "invalid session id" => InvalidSessionId,
+ "javascript error" => JavascriptError,
+ "move target out of bounds" => MoveTargetOutOfBounds,
+ "no such alert" => NoSuchAlert,
+ "no such element" => NoSuchElement,
+ "no such frame" => NoSuchFrame,
+ "no such shadow root" => NoSuchShadowRoot,
+ "no such window" => NoSuchWindow,
+ "script timeout" => ScriptTimeout,
+ "session not created" => SessionNotCreated,
+ "stale element reference" => StaleElementReference,
+ "timeout" => Timeout,
+ "unable to capture screen" => UnableToCaptureScreen,
+ "unable to set cookie" => UnableToSetCookie,
+ "unexpected alert open" => UnexpectedAlertOpen,
+ "unknown command" => UnknownCommand,
+ "unknown error" => UnknownError,
+ "unsupported operation" => UnsupportedOperation,
+ _ => UnknownError,
+ }
+ }
+}
+
+pub type WebDriverResult<T> = Result<T, WebDriverError>;
+
+#[derive(Debug, PartialEq, Serialize, Error)]
+#[serde(remote = "Self")]
+#[error("{}", .error.error_code())]
+pub struct WebDriverError {
+ pub error: ErrorStatus,
+ pub message: Cow<'static, str>,
+ #[serde(rename = "stacktrace")]
+ pub stack: Cow<'static, str>,
+ #[serde(skip)]
+ pub delete_session: bool,
+}
+
+impl Serialize for WebDriverError {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ #[derive(Serialize)]
+ struct Wrapper<'a> {
+ #[serde(with = "WebDriverError")]
+ value: &'a WebDriverError,
+ }
+
+ Wrapper { value: self }.serialize(serializer)
+ }
+}
+
+impl WebDriverError {
+ pub fn new<S>(error: ErrorStatus, message: S) -> WebDriverError
+ where
+ S: Into<Cow<'static, str>>,
+ {
+ WebDriverError {
+ error,
+ message: message.into(),
+ stack: "".into(),
+ delete_session: false,
+ }
+ }
+
+ pub fn new_with_stack<S>(error: ErrorStatus, message: S, stack: S) -> WebDriverError
+ where
+ S: Into<Cow<'static, str>>,
+ {
+ WebDriverError {
+ error,
+ message: message.into(),
+ stack: stack.into(),
+ delete_session: false,
+ }
+ }
+
+ pub fn error_code(&self) -> &'static str {
+ self.error.error_code()
+ }
+
+ pub fn http_status(&self) -> StatusCode {
+ self.error.http_status()
+ }
+}
+
+impl From<serde_json::Error> for WebDriverError {
+ fn from(err: serde_json::Error) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::InvalidArgument, err.to_string())
+ }
+}
+
+impl From<io::Error> for WebDriverError {
+ fn from(err: io::Error) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::UnknownError, err.to_string())
+ }
+}
+
+impl From<DecodeError> for WebDriverError {
+ fn from(err: DecodeError) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::UnknownError, err.to_string())
+ }
+}
+
+impl From<Box<dyn error::Error>> for WebDriverError {
+ fn from(err: Box<dyn error::Error>) -> WebDriverError {
+ WebDriverError::new(ErrorStatus::UnknownError, err.to_string())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+ use crate::test::assert_ser;
+
+ #[test]
+ fn test_json_webdriver_error() {
+ let json = json!({"value": {
+ "error": "unknown error",
+ "message": "foo bar",
+ "stacktrace": "foo\nbar",
+ }});
+ let error = WebDriverError {
+ error: ErrorStatus::UnknownError,
+ message: "foo bar".into(),
+ stack: "foo\nbar".into(),
+ delete_session: true,
+ };
+
+ assert_ser(&error, json);
+ }
+
+ #[test]
+ fn test_json_error_status() {
+ assert_ser(&ErrorStatus::UnknownError, json!("unknown error"));
+ }
+}
diff --git a/testing/webdriver/src/httpapi.rs b/testing/webdriver/src/httpapi.rs
new file mode 100644
index 0000000000..8a23992389
--- /dev/null
+++ b/testing/webdriver/src/httpapi.rs
@@ -0,0 +1,451 @@
+/* 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 http::Method;
+use serde_json::Value;
+
+use crate::command::{VoidWebDriverExtensionCommand, WebDriverCommand, WebDriverExtensionCommand};
+use crate::error::WebDriverResult;
+use crate::Parameters;
+
+pub fn standard_routes<U: WebDriverExtensionRoute>() -> Vec<(Method, &'static str, Route<U>)> {
+ vec![
+ (Method::POST, "/session", Route::NewSession),
+ (Method::DELETE, "/session/{sessionId}", Route::DeleteSession),
+ (Method::POST, "/session/{sessionId}/url", Route::Get),
+ (
+ Method::GET,
+ "/session/{sessionId}/url",
+ Route::GetCurrentUrl,
+ ),
+ (Method::POST, "/session/{sessionId}/back", Route::GoBack),
+ (
+ Method::POST,
+ "/session/{sessionId}/forward",
+ Route::GoForward,
+ ),
+ (Method::POST, "/session/{sessionId}/refresh", Route::Refresh),
+ (Method::GET, "/session/{sessionId}/title", Route::GetTitle),
+ (
+ Method::GET,
+ "/session/{sessionId}/source",
+ Route::GetPageSource,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/window",
+ Route::GetWindowHandle,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/window/handles",
+ Route::GetWindowHandles,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/new",
+ Route::NewWindow,
+ ),
+ (
+ Method::DELETE,
+ "/session/{sessionId}/window",
+ Route::CloseWindow,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/window/size",
+ Route::GetWindowSize,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/size",
+ Route::SetWindowSize,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/window/position",
+ Route::GetWindowPosition,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/position",
+ Route::SetWindowPosition,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/window/rect",
+ Route::GetWindowRect,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/rect",
+ Route::SetWindowRect,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/minimize",
+ Route::MinimizeWindow,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/maximize",
+ Route::MaximizeWindow,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window/fullscreen",
+ Route::FullscreenWindow,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/window",
+ Route::SwitchToWindow,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/frame",
+ Route::SwitchToFrame,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/frame/parent",
+ Route::SwitchToParentFrame,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element",
+ Route::FindElement,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element/{elementId}/element",
+ Route::FindElementElement,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/elements",
+ Route::FindElements,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element/{elementId}/elements",
+ Route::FindElementElements,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/shadow/{shadowId}/element",
+ Route::FindShadowRootElement,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/shadow/{shadowId}/elements",
+ Route::FindShadowRootElements,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/active",
+ Route::GetActiveElement,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/shadow",
+ Route::GetShadowRoot,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/displayed",
+ Route::IsDisplayed,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/selected",
+ Route::IsSelected,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/attribute/{name}",
+ Route::GetElementAttribute,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/property/{name}",
+ Route::GetElementProperty,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/css/{propertyName}",
+ Route::GetCSSValue,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/text",
+ Route::GetElementText,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/computedlabel",
+ Route::GetComputedLabel,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/computedrole",
+ Route::GetComputedRole,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/name",
+ Route::GetElementTagName,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/rect",
+ Route::GetElementRect,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/enabled",
+ Route::IsEnabled,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/execute/sync",
+ Route::ExecuteScript,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/execute/async",
+ Route::ExecuteAsyncScript,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/cookie",
+ Route::GetCookies,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/cookie/{name}",
+ Route::GetNamedCookie,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/cookie",
+ Route::AddCookie,
+ ),
+ (
+ Method::DELETE,
+ "/session/{sessionId}/cookie",
+ Route::DeleteCookies,
+ ),
+ (
+ Method::DELETE,
+ "/session/{sessionId}/cookie/{name}",
+ Route::DeleteCookie,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/timeouts",
+ Route::GetTimeouts,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/timeouts",
+ Route::SetTimeouts,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element/{elementId}/click",
+ Route::ElementClick,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element/{elementId}/clear",
+ Route::ElementClear,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/element/{elementId}/value",
+ Route::ElementSendKeys,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/alert/dismiss",
+ Route::DismissAlert,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/alert/accept",
+ Route::AcceptAlert,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/alert/text",
+ Route::GetAlertText,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/alert/text",
+ Route::SendAlertText,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/screenshot",
+ Route::TakeScreenshot,
+ ),
+ (
+ Method::GET,
+ "/session/{sessionId}/element/{elementId}/screenshot",
+ Route::TakeElementScreenshot,
+ ),
+ (
+ Method::POST,
+ "/session/{sessionId}/actions",
+ Route::PerformActions,
+ ),
+ (
+ Method::DELETE,
+ "/session/{sessionId}/actions",
+ Route::ReleaseActions,
+ ),
+ (Method::POST, "/session/{sessionId}/print", Route::Print),
+ (
+ Method::POST,
+ "/sessions/{sessionId}/webauthn/authenticator",
+ Route::WebAuthnAddVirtualAuthenticator,
+ ),
+ (
+ Method::DELETE,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}",
+ Route::WebAuthnRemoveVirtualAuthenticator,
+ ),
+ (
+ Method::POST,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}/credential",
+ Route::WebAuthnAddCredential,
+ ),
+ (
+ Method::GET,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}/credentials",
+ Route::WebAuthnGetCredentials,
+ ),
+ (
+ Method::DELETE,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}/credentials/{credentialId}",
+ Route::WebAuthnRemoveCredential,
+ ),
+ (
+ Method::DELETE,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}/credentials",
+ Route::WebAuthnRemoveAllCredentials,
+ ),
+ (
+ Method::POST,
+ "/sessions/{sessionId}/webauthn/authenticator/{authenticatorId}/uv",
+ Route::WebAuthnSetUserVerified,
+ ),
+ (Method::GET, "/status", Route::Status),
+ ]
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum Route<U: WebDriverExtensionRoute> {
+ NewSession,
+ DeleteSession,
+ Get,
+ GetCurrentUrl,
+ GoBack,
+ GoForward,
+ Refresh,
+ GetTitle,
+ GetPageSource,
+ GetWindowHandle,
+ GetWindowHandles,
+ NewWindow,
+ CloseWindow,
+ GetWindowSize, // deprecated
+ SetWindowSize, // deprecated
+ GetWindowPosition, // deprecated
+ SetWindowPosition, // deprecated
+ GetWindowRect,
+ SetWindowRect,
+ MinimizeWindow,
+ MaximizeWindow,
+ FullscreenWindow,
+ SwitchToWindow,
+ SwitchToFrame,
+ SwitchToParentFrame,
+ FindElement,
+ FindElements,
+ FindElementElement,
+ FindElementElements,
+ FindShadowRootElement,
+ FindShadowRootElements,
+ GetActiveElement,
+ GetShadowRoot,
+ IsDisplayed,
+ IsSelected,
+ GetElementAttribute,
+ GetElementProperty,
+ GetCSSValue,
+ GetElementText,
+ GetComputedLabel,
+ GetComputedRole,
+ GetElementTagName,
+ GetElementRect,
+ IsEnabled,
+ ExecuteScript,
+ ExecuteAsyncScript,
+ GetCookies,
+ GetNamedCookie,
+ AddCookie,
+ DeleteCookies,
+ DeleteCookie,
+ GetTimeouts,
+ SetTimeouts,
+ ElementClick,
+ ElementClear,
+ ElementSendKeys,
+ PerformActions,
+ ReleaseActions,
+ DismissAlert,
+ AcceptAlert,
+ GetAlertText,
+ SendAlertText,
+ TakeScreenshot,
+ TakeElementScreenshot,
+ Print,
+ Status,
+ Extension(U),
+ WebAuthnAddVirtualAuthenticator,
+ WebAuthnRemoveVirtualAuthenticator,
+ WebAuthnAddCredential,
+ WebAuthnGetCredentials,
+ WebAuthnRemoveCredential,
+ WebAuthnRemoveAllCredentials,
+ WebAuthnSetUserVerified,
+}
+
+pub trait WebDriverExtensionRoute: Clone + Send + PartialEq {
+ type Command: WebDriverExtensionCommand + 'static;
+
+ fn command(
+ &self,
+ _: &Parameters,
+ _: &Value,
+ ) -> WebDriverResult<WebDriverCommand<Self::Command>>;
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct VoidWebDriverExtensionRoute;
+
+impl WebDriverExtensionRoute for VoidWebDriverExtensionRoute {
+ type Command = VoidWebDriverExtensionCommand;
+
+ fn command(
+ &self,
+ _: &Parameters,
+ _: &Value,
+ ) -> WebDriverResult<WebDriverCommand<VoidWebDriverExtensionCommand>> {
+ panic!("No extensions implemented");
+ }
+}
diff --git a/testing/webdriver/src/lib.rs b/testing/webdriver/src/lib.rs
new file mode 100644
index 0000000000..104bc39a9e
--- /dev/null
+++ b/testing/webdriver/src/lib.rs
@@ -0,0 +1,39 @@
+#![allow(non_snake_case)]
+/* 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/. */
+#![forbid(unsafe_code)]
+
+extern crate base64;
+extern crate cookie;
+extern crate icu_segmenter;
+#[macro_use]
+extern crate log;
+extern crate http;
+extern crate serde;
+#[macro_use]
+extern crate serde_derive;
+extern crate serde_json;
+extern crate time;
+#[cfg(feature = "server")]
+extern crate tokio;
+extern crate url;
+#[cfg(feature = "server")]
+extern crate warp;
+
+#[macro_use]
+pub mod macros;
+pub mod actions;
+pub mod capabilities;
+pub mod command;
+pub mod common;
+pub mod error;
+pub mod httpapi;
+pub mod response;
+#[cfg(feature = "server")]
+pub mod server;
+
+#[cfg(test)]
+pub mod test;
+
+pub use common::Parameters;
diff --git a/testing/webdriver/src/macros.rs b/testing/webdriver/src/macros.rs
new file mode 100644
index 0000000000..799c43cba3
--- /dev/null
+++ b/testing/webdriver/src/macros.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/. */
+
+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)),
+ }
+ }};
+}
diff --git a/testing/webdriver/src/response.rs b/testing/webdriver/src/response.rs
new file mode 100644
index 0000000000..3b4010c798
--- /dev/null
+++ b/testing/webdriver/src/response.rs
@@ -0,0 +1,332 @@
+/* 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::common::{Cookie, CredentialParameters};
+use serde::ser::{Serialize, Serializer};
+use serde_json::Value;
+
+#[derive(Debug, PartialEq, Serialize)]
+#[serde(untagged, remote = "Self")]
+pub enum WebDriverResponse {
+ NewWindow(NewWindowResponse),
+ CloseWindow(CloseWindowResponse),
+ Cookie(CookieResponse),
+ Cookies(CookiesResponse),
+ DeleteSession,
+ ElementRect(ElementRectResponse),
+ Generic(ValueResponse),
+ WebAuthnAddVirtualAuthenticator(u64),
+ WebAuthnGetCredentials(GetCredentialsResponse),
+ NewSession(NewSessionResponse),
+ Timeouts(TimeoutsResponse),
+ Void,
+ WindowRect(WindowRectResponse),
+}
+
+impl Serialize for WebDriverResponse {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ #[derive(Serialize)]
+ struct Wrapper<'a> {
+ #[serde(with = "WebDriverResponse")]
+ value: &'a WebDriverResponse,
+ }
+
+ Wrapper { value: self }.serialize(serializer)
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct NewWindowResponse {
+ pub handle: String,
+ #[serde(rename = "type")]
+ pub typ: String,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct CloseWindowResponse(pub Vec<String>);
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct CookieResponse(pub Cookie);
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct CookiesResponse(pub Vec<Cookie>);
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct ElementRectResponse {
+ /// X axis position of the top-left corner of the element relative
+ /// to the current browsing context’s document element in CSS reference
+ /// pixels.
+ pub x: f64,
+
+ /// Y axis position of the top-left corner of the element relative
+ /// to the current browsing context’s document element in CSS reference
+ /// pixels.
+ pub y: f64,
+
+ /// Height of the element’s [bounding rectangle] in CSS reference
+ /// pixels.
+ ///
+ /// [bounding rectangle]: https://drafts.fxtf.org/geometry/#rectangle
+ pub width: f64,
+
+ /// Width of the element’s [bounding rectangle] in CSS reference
+ /// pixels.
+ ///
+ /// [bounding rectangle]: https://drafts.fxtf.org/geometry/#rectangle
+ pub height: f64,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct GetCredentialsResponse(pub Vec<CredentialParameters>);
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct NewSessionResponse {
+ #[serde(rename = "sessionId")]
+ pub session_id: String,
+ pub capabilities: Value,
+}
+
+impl NewSessionResponse {
+ pub fn new(session_id: String, capabilities: Value) -> NewSessionResponse {
+ NewSessionResponse {
+ session_id,
+ capabilities,
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct TimeoutsResponse {
+ pub script: Option<u64>,
+ #[serde(rename = "pageLoad")]
+ pub page_load: u64,
+ pub implicit: u64,
+}
+
+impl TimeoutsResponse {
+ pub fn new(script: Option<u64>, page_load: u64, implicit: u64) -> TimeoutsResponse {
+ TimeoutsResponse {
+ script,
+ page_load,
+ implicit,
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct ValueResponse(pub Value);
+
+#[derive(Debug, PartialEq, Serialize)]
+pub struct WindowRectResponse {
+ /// `WindowProxy`’s [screenX] attribute.
+ ///
+ /// [screenX]: https://drafts.csswg.org/cssom-view/#dom-window-screenx
+ pub x: i32,
+
+ /// `WindowProxy`’s [screenY] attribute.
+ ///
+ /// [screenY]: https://drafts.csswg.org/cssom-view/#dom-window-screeny
+ pub y: i32,
+
+ /// Width of the top-level browsing context’s outer dimensions, including
+ /// any browser chrome and externally drawn window decorations in CSS
+ /// reference pixels.
+ pub width: i32,
+
+ /// Height of the top-level browsing context’s outer dimensions, including
+ /// any browser chrome and externally drawn window decorations in CSS
+ /// reference pixels.
+ pub height: i32,
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::{json, Map};
+
+ use super::*;
+ use crate::common::Date;
+ use crate::test::assert_ser;
+
+ #[test]
+ fn test_json_new_window_response() {
+ let json = json!({"value": {"handle": "42", "type": "window"}});
+ let response = WebDriverResponse::NewWindow(NewWindowResponse {
+ handle: "42".into(),
+ typ: "window".into(),
+ });
+
+ assert_ser(&response, json);
+ }
+
+ #[test]
+ fn test_json_close_window_response() {
+ assert_ser(
+ &WebDriverResponse::CloseWindow(CloseWindowResponse(vec!["1234".into()])),
+ json!({"value": ["1234"]}),
+ );
+ }
+
+ #[test]
+ fn test_json_cookie_response_with_optional() {
+ let json = json!({"value": {
+ "name": "foo",
+ "value": "bar",
+ "path": "/",
+ "domain": "foo.bar",
+ "secure": true,
+ "httpOnly": false,
+ "expiry": 123,
+ "sameSite": "Strict",
+ }});
+ let response = WebDriverResponse::Cookie(CookieResponse(Cookie {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: Some("/".into()),
+ domain: Some("foo.bar".into()),
+ expiry: Some(Date(123)),
+ secure: true,
+ http_only: false,
+ same_site: Some("Strict".into()),
+ }));
+
+ assert_ser(&response, json);
+ }
+
+ #[test]
+ fn test_json_cookie_response_without_optional() {
+ let json = json!({"value": {
+ "name": "foo",
+ "value": "bar",
+ "path": "/",
+ "domain": null,
+ "secure": true,
+ "httpOnly": false,
+ }});
+ let response = WebDriverResponse::Cookie(CookieResponse(Cookie {
+ name: "foo".into(),
+ value: "bar".into(),
+ path: Some("/".into()),
+ domain: None,
+ expiry: None,
+ secure: true,
+ http_only: false,
+ same_site: None,
+ }));
+
+ assert_ser(&response, json);
+ }
+
+ #[test]
+ fn test_json_cookies_response() {
+ let json = json!({"value": [{
+ "name": "name",
+ "value": "value",
+ "path": "/",
+ "domain": null,
+ "secure": true,
+ "httpOnly": false,
+ "sameSite": "None",
+ }]});
+ let response = WebDriverResponse::Cookies(CookiesResponse(vec![Cookie {
+ name: "name".into(),
+ value: "value".into(),
+ path: Some("/".into()),
+ domain: None,
+ expiry: None,
+ secure: true,
+ http_only: false,
+ same_site: Some("None".into()),
+ }]));
+
+ assert_ser(&response, json);
+ }
+
+ #[test]
+ fn test_json_delete_session_response() {
+ assert_ser(&WebDriverResponse::DeleteSession, json!({ "value": null }));
+ }
+
+ #[test]
+ fn test_json_element_rect_response() {
+ let json = json!({"value": {
+ "x": 0.0,
+ "y": 1.0,
+ "width": 2.0,
+ "height": 3.0,
+ }});
+ let response = WebDriverResponse::ElementRect(ElementRectResponse {
+ x: 0f64,
+ y: 1f64,
+ width: 2f64,
+ height: 3f64,
+ });
+
+ assert_ser(&response, json);
+ }
+
+ #[test]
+ fn test_json_generic_value_response() {
+ let response = {
+ let mut value = Map::new();
+ value.insert(
+ "example".into(),
+ Value::Array(vec![Value::String("test".into())]),
+ );
+ WebDriverResponse::Generic(ValueResponse(Value::Object(value)))
+ };
+ assert_ser(&response, json!({"value": {"example": ["test"]}}));
+ }
+
+ #[test]
+ fn test_json_new_session_response() {
+ let response =
+ WebDriverResponse::NewSession(NewSessionResponse::new("id".into(), json!({})));
+ assert_ser(
+ &response,
+ json!({"value": {"sessionId": "id", "capabilities": {}}}),
+ );
+ }
+
+ #[test]
+ fn test_json_timeouts_response() {
+ assert_ser(
+ &WebDriverResponse::Timeouts(TimeoutsResponse::new(Some(1), 2, 3)),
+ json!({"value": {"script": 1, "pageLoad": 2, "implicit": 3}}),
+ );
+ }
+
+ #[test]
+ fn test_json_timeouts_response_with_null_script_timeout() {
+ assert_ser(
+ &WebDriverResponse::Timeouts(TimeoutsResponse::new(None, 2, 3)),
+ json!({"value": {"script": null, "pageLoad": 2, "implicit": 3}}),
+ );
+ }
+
+ #[test]
+ fn test_json_void_response() {
+ assert_ser(&WebDriverResponse::Void, json!({ "value": null }));
+ }
+
+ #[test]
+ fn test_json_window_rect_response() {
+ let json = json!({"value": {
+ "x": 0,
+ "y": 1,
+ "width": 2,
+ "height": 3,
+ }});
+ let response = WebDriverResponse::WindowRect(WindowRectResponse {
+ x: 0i32,
+ y: 1i32,
+ width: 2i32,
+ height: 3i32,
+ });
+
+ assert_ser(&response, json);
+ }
+}
diff --git a/testing/webdriver/src/server.rs b/testing/webdriver/src/server.rs
new file mode 100644
index 0000000000..3aa55c690e
--- /dev/null
+++ b/testing/webdriver/src/server.rs
@@ -0,0 +1,691 @@
+/* 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::{WebDriverCommand, WebDriverMessage};
+use crate::error::{ErrorStatus, WebDriverError, WebDriverResult};
+use crate::httpapi::{
+ standard_routes, Route, VoidWebDriverExtensionRoute, WebDriverExtensionRoute,
+};
+use crate::response::{CloseWindowResponse, WebDriverResponse};
+use crate::Parameters;
+use bytes::Bytes;
+use http::{self, Method, StatusCode};
+use std::marker::PhantomData;
+use std::net::{SocketAddr, TcpListener as StdTcpListener};
+use std::sync::mpsc::{channel, Receiver, Sender};
+use std::sync::{Arc, Mutex};
+use std::thread;
+use tokio::net::TcpListener;
+use tokio_stream::wrappers::TcpListenerStream;
+use url::{Host, Url};
+use warp::{self, Buf, Filter, Rejection};
+
+// Silence warning about Quit being unused for now.
+#[allow(dead_code)]
+enum DispatchMessage<U: WebDriverExtensionRoute> {
+ HandleWebDriver(
+ WebDriverMessage<U>,
+ Sender<WebDriverResult<WebDriverResponse>>,
+ ),
+ Quit,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+/// Representation of whether we managed to successfully send a DeleteSession message
+/// and read the response during session teardown.
+pub enum SessionTeardownKind {
+ /// A DeleteSession message has been sent and the response handled.
+ Deleted,
+ /// No DeleteSession message has been sent, or the response was not received.
+ NotDeleted,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Session {
+ pub id: String,
+}
+
+impl Session {
+ fn new(id: String) -> Session {
+ Session { id }
+ }
+}
+
+pub trait WebDriverHandler<U: WebDriverExtensionRoute = VoidWebDriverExtensionRoute>: Send {
+ fn handle_command(
+ &mut self,
+ session: &Option<Session>,
+ msg: WebDriverMessage<U>,
+ ) -> WebDriverResult<WebDriverResponse>;
+ fn teardown_session(&mut self, kind: SessionTeardownKind);
+}
+
+#[derive(Debug)]
+struct Dispatcher<T: WebDriverHandler<U>, U: WebDriverExtensionRoute> {
+ handler: T,
+ session: Option<Session>,
+ extension_type: PhantomData<U>,
+}
+
+impl<T: WebDriverHandler<U>, U: WebDriverExtensionRoute> Dispatcher<T, U> {
+ fn new(handler: T) -> Dispatcher<T, U> {
+ Dispatcher {
+ handler,
+ session: None,
+ extension_type: PhantomData,
+ }
+ }
+
+ fn run(&mut self, msg_chan: &Receiver<DispatchMessage<U>>) {
+ loop {
+ match msg_chan.recv() {
+ Ok(DispatchMessage::HandleWebDriver(msg, resp_chan)) => {
+ let resp = match self.check_session(&msg) {
+ Ok(_) => self.handler.handle_command(&self.session, msg),
+ Err(e) => Err(e),
+ };
+
+ match resp {
+ Ok(WebDriverResponse::NewSession(ref new_session)) => {
+ self.session = Some(Session::new(new_session.session_id.clone()));
+ }
+ Ok(WebDriverResponse::CloseWindow(CloseWindowResponse(ref handles))) => {
+ if handles.is_empty() {
+ debug!("Last window was closed, deleting session");
+ // The teardown_session implementation is responsible for actually
+ // sending the DeleteSession message in this case
+ self.teardown_session(SessionTeardownKind::NotDeleted);
+ }
+ }
+ Ok(WebDriverResponse::DeleteSession) => {
+ self.teardown_session(SessionTeardownKind::Deleted);
+ }
+ Err(ref x) if x.delete_session => {
+ // This includes the case where we failed during session creation
+ self.teardown_session(SessionTeardownKind::NotDeleted)
+ }
+ _ => {}
+ }
+
+ if resp_chan.send(resp).is_err() {
+ error!("Sending response to the main thread failed");
+ };
+ }
+ Ok(DispatchMessage::Quit) => break,
+ Err(e) => panic!("Error receiving message in handler: {:?}", e),
+ }
+ }
+ }
+
+ fn teardown_session(&mut self, kind: SessionTeardownKind) {
+ debug!("Teardown session");
+ let final_kind = match kind {
+ SessionTeardownKind::NotDeleted if self.session.is_some() => {
+ let delete_session = WebDriverMessage {
+ session_id: Some(
+ self.session
+ .as_ref()
+ .expect("Failed to get session")
+ .id
+ .clone(),
+ ),
+ command: WebDriverCommand::DeleteSession,
+ };
+ match self.handler.handle_command(&self.session, delete_session) {
+ Ok(_) => SessionTeardownKind::Deleted,
+ Err(_) => SessionTeardownKind::NotDeleted,
+ }
+ }
+ _ => kind,
+ };
+ self.handler.teardown_session(final_kind);
+ self.session = None;
+ }
+
+ fn check_session(&self, msg: &WebDriverMessage<U>) -> WebDriverResult<()> {
+ match msg.session_id {
+ Some(ref msg_session_id) => match self.session {
+ Some(ref existing_session) => {
+ if existing_session.id != *msg_session_id {
+ Err(WebDriverError::new(
+ ErrorStatus::InvalidSessionId,
+ format!("Got unexpected session id {}", msg_session_id),
+ ))
+ } else {
+ Ok(())
+ }
+ }
+ None => Ok(()),
+ },
+ None => {
+ match self.session {
+ Some(_) => {
+ match msg.command {
+ WebDriverCommand::Status => Ok(()),
+ WebDriverCommand::NewSession(_) => Err(WebDriverError::new(
+ ErrorStatus::SessionNotCreated,
+ "Session is already started",
+ )),
+ _ => {
+ //This should be impossible
+ error!("Got a message with no session id");
+ Err(WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Got a command with no session?!",
+ ))
+ }
+ }
+ }
+ None => match msg.command {
+ WebDriverCommand::NewSession(_) => Ok(()),
+ WebDriverCommand::Status => Ok(()),
+ _ => Err(WebDriverError::new(
+ ErrorStatus::InvalidSessionId,
+ "Tried to run a command before creating a session",
+ )),
+ },
+ }
+ }
+ }
+ }
+}
+
+pub struct Listener {
+ guard: Option<thread::JoinHandle<()>>,
+ pub socket: SocketAddr,
+}
+
+impl Drop for Listener {
+ fn drop(&mut self) {
+ let _ = self.guard.take().map(|j| j.join());
+ }
+}
+
+pub fn start<T, U>(
+ mut address: SocketAddr,
+ allow_hosts: Vec<Host>,
+ allow_origins: Vec<Url>,
+ handler: T,
+ extension_routes: Vec<(Method, &'static str, U)>,
+) -> ::std::io::Result<Listener>
+where
+ T: 'static + WebDriverHandler<U>,
+ U: 'static + WebDriverExtensionRoute + Send + Sync,
+{
+ let listener = StdTcpListener::bind(address)?;
+ listener.set_nonblocking(true)?;
+ let addr = listener.local_addr()?;
+ if address.port() == 0 {
+ // If we passed in 0 as the port number the OS will assign an unused port;
+ // we want to update the address to the actual used port
+ address.set_port(addr.port())
+ }
+ let (msg_send, msg_recv) = channel();
+
+ let builder = thread::Builder::new().name("webdriver server".to_string());
+ let handle = builder.spawn(move || {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_io()
+ .build()
+ .unwrap();
+ let listener = rt.block_on(async { TcpListener::from_std(listener).unwrap() });
+ let wroutes = build_warp_routes(
+ address,
+ allow_hosts,
+ allow_origins,
+ &extension_routes,
+ msg_send.clone(),
+ );
+ let fut = warp::serve(wroutes).run_incoming(TcpListenerStream::new(listener));
+ rt.block_on(fut);
+ })?;
+
+ let builder = thread::Builder::new().name("webdriver dispatcher".to_string());
+ builder.spawn(move || {
+ let mut dispatcher = Dispatcher::new(handler);
+ dispatcher.run(&msg_recv);
+ })?;
+
+ Ok(Listener {
+ guard: Some(handle),
+ socket: addr,
+ })
+}
+
+fn build_warp_routes<U: 'static + WebDriverExtensionRoute + Send + Sync>(
+ address: SocketAddr,
+ allow_hosts: Vec<Host>,
+ allow_origins: Vec<Url>,
+ ext_routes: &[(Method, &'static str, U)],
+ chan: Sender<DispatchMessage<U>>,
+) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
+ let chan = Arc::new(Mutex::new(chan));
+ let mut std_routes = standard_routes::<U>();
+ let (method, path, res) = std_routes.pop().unwrap();
+ let mut wroutes = build_route(
+ address,
+ allow_hosts.clone(),
+ allow_origins.clone(),
+ method,
+ path,
+ res,
+ chan.clone(),
+ );
+ for (method, path, res) in std_routes {
+ wroutes = wroutes
+ .or(build_route(
+ address,
+ allow_hosts.clone(),
+ allow_origins.clone(),
+ method,
+ path,
+ res.clone(),
+ chan.clone(),
+ ))
+ .unify()
+ .boxed()
+ }
+ for (method, path, res) in ext_routes {
+ wroutes = wroutes
+ .or(build_route(
+ address,
+ allow_hosts.clone(),
+ allow_origins.clone(),
+ method.clone(),
+ path,
+ Route::Extension(res.clone()),
+ chan.clone(),
+ ))
+ .unify()
+ .boxed()
+ }
+ wroutes
+}
+
+fn is_host_allowed(server_address: &SocketAddr, allow_hosts: &[Host], host_header: &str) -> bool {
+ // Validate that the Host header value has a hostname in allow_hosts and
+ // the port matches the server configuration
+ let header_host_url = match Url::parse(&format!("http://{}", &host_header)) {
+ Ok(x) => x,
+ Err(_) => {
+ return false;
+ }
+ };
+
+ let host = match header_host_url.host() {
+ Some(host) => host.to_owned(),
+ None => {
+ // This shouldn't be possible since http URL always have a
+ // host, but conservatively return false here, which will cause
+ // an error response
+ return false;
+ }
+ };
+ let port = match header_host_url.port_or_known_default() {
+ Some(port) => port,
+ None => {
+ // This shouldn't be possible since http URL always have a
+ // default port, but conservatively return false here, which will cause
+ // an error response
+ return false;
+ }
+ };
+
+ let host_matches = match host {
+ Host::Domain(_) => allow_hosts.contains(&host),
+ Host::Ipv4(_) | Host::Ipv6(_) => true,
+ };
+ let port_matches = server_address.port() == port;
+ host_matches && port_matches
+}
+
+fn is_origin_allowed(allow_origins: &[Url], origin_url: Url) -> bool {
+ // Validate that the Origin header value is in allow_origins
+ allow_origins.contains(&origin_url)
+}
+
+fn build_route<U: 'static + WebDriverExtensionRoute + Send + Sync>(
+ server_address: SocketAddr,
+ allow_hosts: Vec<Host>,
+ allow_origins: Vec<Url>,
+ method: Method,
+ path: &'static str,
+ route: Route<U>,
+ chan: Arc<Mutex<Sender<DispatchMessage<U>>>>,
+) -> warp::filters::BoxedFilter<(impl warp::Reply,)> {
+ // Create an empty filter based on the provided method and append an empty hashmap to it. The
+ // hashmap will be used to store path parameters.
+ let mut subroute = match method {
+ Method::GET => warp::get().boxed(),
+ Method::POST => warp::post().boxed(),
+ Method::DELETE => warp::delete().boxed(),
+ Method::OPTIONS => warp::options().boxed(),
+ Method::PUT => warp::put().boxed(),
+ _ => panic!("Unsupported method"),
+ }
+ .or(warp::head())
+ .unify()
+ .map(Parameters::new)
+ .boxed();
+
+ // For each part of the path, if it's a normal part, just append it to the current filter,
+ // otherwise if it's a parameter (a named enclosed in { }), we take that parameter and insert
+ // it into the hashmap created earlier.
+ for part in path.split('/') {
+ if part.is_empty() {
+ continue;
+ } else if part.starts_with('{') {
+ assert!(part.ends_with('}'));
+
+ subroute = subroute
+ .and(warp::path::param())
+ .map(move |mut params: Parameters, param: String| {
+ let name = &part[1..part.len() - 1];
+ params.insert(name.to_string(), param);
+ params
+ })
+ .boxed();
+ } else {
+ subroute = subroute.and(warp::path(part)).boxed();
+ }
+ }
+
+ // Finally, tell warp that the path is complete
+ subroute
+ .and(warp::path::end())
+ .and(warp::path::full())
+ .and(warp::method())
+ .and(warp::header::optional::<String>("origin"))
+ .and(warp::header::optional::<String>("host"))
+ .and(warp::header::optional::<String>("content-type"))
+ .and(warp::body::bytes())
+ .map(
+ move |params,
+ full_path: warp::path::FullPath,
+ method,
+ origin_header: Option<String>,
+ host_header: Option<String>,
+ content_type_header: Option<String>,
+ body: Bytes| {
+ if method == Method::HEAD {
+ return warp::reply::with_status("".into(), StatusCode::OK);
+ }
+ if let Some(host) = host_header {
+ if !is_host_allowed(&server_address, &allow_hosts, &host) {
+ warn!(
+ "Rejected request with Host header {}, allowed values are [{}]",
+ host,
+ allow_hosts
+ .iter()
+ .map(|x| format!("{}:{}", x, server_address.port()))
+ .collect::<Vec<_>>()
+ .join(",")
+ );
+ let err = WebDriverError::new(
+ ErrorStatus::UnknownError,
+ format!("Invalid Host header {}", host),
+ );
+ return warp::reply::with_status(
+ serde_json::to_string(&err).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ };
+ } else {
+ warn!("Rejected request with missing Host header");
+ let err = WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Missing Host header".to_string(),
+ );
+ return warp::reply::with_status(
+ serde_json::to_string(&err).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ }
+ if let Some(origin) = origin_header {
+ let make_err = || {
+ warn!(
+ "Rejected request with Origin header {}, allowed values are [{}]",
+ origin,
+ allow_origins
+ .iter()
+ .map(|x| x.to_string())
+ .collect::<Vec<_>>()
+ .join(",")
+ );
+ WebDriverError::new(
+ ErrorStatus::UnknownError,
+ format!("Invalid Origin header {}", origin),
+ )
+ };
+ let origin_url = match Url::parse(&origin) {
+ Ok(url) => url,
+ Err(_) => {
+ return warp::reply::with_status(
+ serde_json::to_string(&make_err()).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ }
+ };
+ if !is_origin_allowed(&allow_origins, origin_url) {
+ return warp::reply::with_status(
+ serde_json::to_string(&make_err()).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ }
+ }
+ if method == Method::POST {
+ // Disallow CORS-safelisted request headers
+ // c.f. https://fetch.spec.whatwg.org/#cors-safelisted-request-header
+ let content_type = content_type_header
+ .as_ref()
+ .map(|x| x.find(';').and_then(|idx| x.get(0..idx)).unwrap_or(x))
+ .map(|x| x.trim())
+ .map(|x| x.to_lowercase());
+ match content_type.as_ref().map(|x| x.as_ref()) {
+ Some("application/x-www-form-urlencoded")
+ | Some("multipart/form-data")
+ | Some("text/plain") => {
+ warn!(
+ "Rejected POST request with disallowed content type {}",
+ content_type.unwrap_or_else(|| "".into())
+ );
+ let err = WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Invalid Content-Type",
+ );
+ return warp::reply::with_status(
+ serde_json::to_string(&err).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ }
+ Some(_) | None => {}
+ }
+ }
+ let body = String::from_utf8(body.chunk().to_vec());
+ if body.is_err() {
+ let err = WebDriverError::new(
+ ErrorStatus::UnknownError,
+ "Request body wasn't valid UTF-8",
+ );
+ return warp::reply::with_status(
+ serde_json::to_string(&err).unwrap(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ );
+ }
+ let body = body.unwrap();
+
+ debug!("-> {} {} {}", method, full_path.as_str(), body);
+ let msg_result = WebDriverMessage::from_http(
+ route.clone(),
+ &params,
+ &body,
+ method == Method::POST,
+ );
+
+ let (status, resp_body) = match msg_result {
+ Ok(message) => {
+ let (send_res, recv_res) = channel();
+ match chan.lock() {
+ Ok(ref c) => {
+ let res =
+ c.send(DispatchMessage::HandleWebDriver(message, send_res));
+ match res {
+ Ok(x) => x,
+ Err(e) => panic!("Error: {:?}", e),
+ }
+ }
+ Err(e) => panic!("Error reading response: {:?}", e),
+ }
+
+ match recv_res.recv() {
+ Ok(data) => match data {
+ Ok(response) => {
+ (StatusCode::OK, serde_json::to_string(&response).unwrap())
+ }
+ Err(e) => (e.http_status(), serde_json::to_string(&e).unwrap()),
+ },
+ Err(e) => panic!("Error reading response: {:?}", e),
+ }
+ }
+ Err(e) => (e.http_status(), serde_json::to_string(&e).unwrap()),
+ };
+
+ debug!("<- {} {}", status, resp_body);
+ warp::reply::with_status(resp_body, status)
+ },
+ )
+ .with(warp::reply::with::header(
+ http::header::CONTENT_TYPE,
+ "application/json; charset=utf-8",
+ ))
+ .with(warp::reply::with::header(
+ http::header::CACHE_CONTROL,
+ "no-cache",
+ ))
+ .boxed()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::IpAddr;
+ use std::str::FromStr;
+
+ #[test]
+ fn test_host_allowed() {
+ let addr_80 = SocketAddr::new(IpAddr::from_str("127.0.0.1").unwrap(), 80);
+ let addr_8000 = SocketAddr::new(IpAddr::from_str("127.0.0.1").unwrap(), 8000);
+ let addr_v6_80 = SocketAddr::new(IpAddr::from_str("::1").unwrap(), 80);
+ let addr_v6_8000 = SocketAddr::new(IpAddr::from_str("::1").unwrap(), 8000);
+
+ // We match the host ip address to the server, so we can only use hosts that actually resolve
+ let localhost_host = Host::Domain("localhost".to_string());
+ let test_host = Host::Domain("example.test".to_string());
+ let subdomain_localhost_host = Host::Domain("subdomain.localhost".to_string());
+
+ assert!(is_host_allowed(
+ &addr_80,
+ &[localhost_host.clone()],
+ "localhost:80"
+ ));
+ assert!(is_host_allowed(
+ &addr_80,
+ &[test_host.clone()],
+ "example.test:80"
+ ));
+ assert!(is_host_allowed(
+ &addr_80,
+ &[test_host.clone(), localhost_host.clone()],
+ "example.test"
+ ));
+ assert!(is_host_allowed(
+ &addr_80,
+ &[subdomain_localhost_host.clone()],
+ "subdomain.localhost"
+ ));
+
+ // ip address cases
+ assert!(is_host_allowed(&addr_80, &[], "127.0.0.1:80"));
+ assert!(is_host_allowed(&addr_v6_80, &[], "127.0.0.1"));
+ assert!(is_host_allowed(&addr_80, &[], "[::1]"));
+ assert!(is_host_allowed(&addr_8000, &[], "127.0.0.1:8000"));
+ assert!(is_host_allowed(
+ &addr_80,
+ &[subdomain_localhost_host.clone()],
+ "[::1]"
+ ));
+ assert!(is_host_allowed(
+ &addr_v6_8000,
+ &[subdomain_localhost_host.clone()],
+ "[::1]:8000"
+ ));
+
+ // Mismatch cases
+
+ assert!(!is_host_allowed(&addr_80, &[test_host], "localhost"));
+
+ assert!(!is_host_allowed(&addr_80, &[], "localhost:80"));
+
+ // Port mismatch cases
+
+ assert!(!is_host_allowed(
+ &addr_80,
+ &[localhost_host.clone()],
+ "localhost:8000"
+ ));
+ assert!(!is_host_allowed(
+ &addr_8000,
+ &[localhost_host.clone()],
+ "localhost"
+ ));
+ assert!(!is_host_allowed(
+ &addr_v6_8000,
+ &[localhost_host.clone()],
+ "[::1]"
+ ));
+ }
+
+ #[test]
+ fn test_origin_allowed() {
+ assert!(is_origin_allowed(
+ &[Url::parse("http://localhost").unwrap()],
+ Url::parse("http://localhost").unwrap()
+ ));
+ assert!(is_origin_allowed(
+ &[Url::parse("http://localhost").unwrap()],
+ Url::parse("http://localhost:80").unwrap()
+ ));
+ assert!(is_origin_allowed(
+ &[
+ Url::parse("https://test.example").unwrap(),
+ Url::parse("http://localhost").unwrap()
+ ],
+ Url::parse("http://localhost").unwrap()
+ ));
+ assert!(is_origin_allowed(
+ &[
+ Url::parse("https://test.example").unwrap(),
+ Url::parse("http://localhost").unwrap()
+ ],
+ Url::parse("https://test.example:443").unwrap()
+ ));
+ // Mismatch cases
+ assert!(!is_origin_allowed(
+ &[],
+ Url::parse("http://localhost").unwrap()
+ ));
+ assert!(!is_origin_allowed(
+ &[Url::parse("http://localhost").unwrap()],
+ Url::parse("http://localhost:8000").unwrap()
+ ));
+ assert!(!is_origin_allowed(
+ &[Url::parse("https://localhost").unwrap()],
+ Url::parse("http://localhost").unwrap()
+ ));
+ assert!(!is_origin_allowed(
+ &[Url::parse("https://example.test").unwrap()],
+ Url::parse("http://subdomain.example.test").unwrap()
+ ));
+ }
+}
diff --git a/testing/webdriver/src/test.rs b/testing/webdriver/src/test.rs
new file mode 100644
index 0000000000..4d51c1cea7
--- /dev/null
+++ b/testing/webdriver/src/test.rs
@@ -0,0 +1,32 @@
+/* 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_ser_de<T>(data: &T, json: serde_json::Value)
+where
+ T: std::fmt::Debug,
+ T: std::cmp::PartialEq,
+ T: serde::de::DeserializeOwned,
+ T: serde::Serialize,
+{
+ assert_eq!(serde_json::to_value(data).unwrap(), json);
+ assert_eq!(data, &serde_json::from_value::<T>(json).unwrap());
+}
+
+pub fn assert_ser<T>(data: &T, json: serde_json::Value)
+where
+ T: std::fmt::Debug,
+ T: std::cmp::PartialEq,
+ T: serde::Serialize,
+{
+ assert_eq!(serde_json::to_value(data).unwrap(), json);
+}
+
+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());
+}