summaryrefslogtreecommitdiffstats
path: root/testing/webdriver/src/capabilities.rs
diff options
context:
space:
mode:
Diffstat (limited to 'testing/webdriver/src/capabilities.rs')
-rw-r--r--testing/webdriver/src/capabilities.rs754
1 files changed, 754 insertions, 0 deletions
diff --git a/testing/webdriver/src/capabilities.rs b/testing/webdriver/src/capabilities.rs
new file mode 100644
index 0000000000..53dd8306b0
--- /dev/null
+++ b/testing/webdriver/src/capabilities.rs
@@ -0,0 +1,754 @@
+/* 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>;
+
+ 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" => {
+ 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;
+ }
+ }
+ 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());
+ }
+}