diff options
Diffstat (limited to 'testing/webdriver')
-rw-r--r-- | testing/webdriver/Cargo.toml | 39 | ||||
-rw-r--r-- | testing/webdriver/README.md | 44 | ||||
-rw-r--r-- | testing/webdriver/moz.build | 8 | ||||
-rw-r--r-- | testing/webdriver/src/actions.rs | 1397 | ||||
-rw-r--r-- | testing/webdriver/src/capabilities.rs | 823 | ||||
-rw-r--r-- | testing/webdriver/src/command.rs | 1710 | ||||
-rw-r--r-- | testing/webdriver/src/common.rs | 319 | ||||
-rw-r--r-- | testing/webdriver/src/error.rs | 405 | ||||
-rw-r--r-- | testing/webdriver/src/httpapi.rs | 451 | ||||
-rw-r--r-- | testing/webdriver/src/lib.rs | 39 | ||||
-rw-r--r-- | testing/webdriver/src/macros.rs | 12 | ||||
-rw-r--r-- | testing/webdriver/src/response.rs | 332 | ||||
-rw-r--r-- | testing/webdriver/src/server.rs | 691 | ||||
-rw-r--r-- | testing/webdriver/src/test.rs | 32 |
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(¶ms, 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( + ¶ms, + 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( + ¶ms, + 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( + ¶ms, + 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(¶ms, 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(), + ¶ms, + &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()); +} |