Bug 1414322 - Use WebDriver conformant interactability checks for sendKeysToElement. r=ato

Enables webdriver spec keyboard interactability tests for 'Element Send Keys'
by default by re-using the same capability 'moz:webdriverClick' from
'Element Click'. It can be disabled by turning off this preference.

Also various webplatform tests for webdriver spec have been added which
cover both the scroll into view action, and keyboard interactability check.

Existing Marionette unit tests will be run in both modes, until we can
get rid of the legacy mode.

MozReview-Commit-ID: dFB8sQ6CN5

--HG--
extra : rebase_source : 9f6a3c3e42f779f039f61d0b239f9f5925ecdcf5
This commit is contained in:
Henrik Skupin 2017-11-10 20:29:04 +01:00
parent 88c43fb94d
commit cb6d8c72b5
14 changed files with 362 additions and 42 deletions

View File

@ -284,12 +284,14 @@ moz:webdriverClick
------------------
A boolean value to indicate which kind of interactability checks to run
when performing a click on elements. For Firefoxen prior to version 58.0 some
legacy code as imported from an older version of [FirefoxDriver] was in use.
when performing a click or sending keys to an elements. For Firefoxen prior to
version 58.0 some legacy code as imported from an older version of
[FirefoxDriver] was in use.
With Firefox 58 the interactability checks as required by the [WebDriver]
specification are enabled by default. This means geckodriver will additionally
check if an element is obscured by another when clicking.
check if an element is obscured by another when clicking, and if an element is
focusable for sending keys.
Because of this change in behaviour, we are aware that some extra errors could
be returned. In most cases the test in question might have to be updated
@ -299,6 +301,8 @@ geckodriver, then please raise an issue in the [issue tracker].
To temporarily disable the WebDriver conformant checks use `false` as value
for this capability.
Please note that this capability exists only temporarily, and that it will be
removed once the interactability checks have been stabilized.
`log` object
------------

View File

@ -2,6 +2,8 @@
# 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/.
import urllib
from marionette_driver.by import By
from marionette_driver.keys import Keys
from marionette_driver.marionette import Actions
@ -9,6 +11,10 @@ from marionette_driver.marionette import Actions
from marionette_harness import MarionetteTestCase
def inline(doc):
return "data:text/html;charset=utf-8,{}".format(urllib.quote(doc))
class TestMouseAction(MarionetteTestCase):
def setUp(self):
MarionetteTestCase.setUp(self)
@ -34,12 +40,14 @@ class TestMouseAction(MarionetteTestCase):
self.action.click(el).perform()
def test_double_click_action(self):
test_html = self.marionette.absolute_url("double_click.html")
self.marionette.navigate(test_html)
el = self.marionette.find_element(By.ID, "one-word-div")
self.marionette.navigate(inline("""
<div contenteditable>zyxw</div><input type="text"/>
"""))
el = self.marionette.find_element(By.CSS_SELECTOR, "div")
self.action.double_click(el).perform()
el.send_keys(self.mod_key + "c")
rel = self.marionette.find_element(By.ID, "input-field")
rel = self.marionette.find_element(By.CSS_SELECTOR, "input")
rel.send_keys(self.mod_key + "v")
self.assertEqual("zyxw", rel.get_property("value"))

View File

@ -321,13 +321,6 @@ class TestTypingContent(TypingTestCase):
# If we don't get an error below we are good
self.marionette.find_element(By.TAG_NAME, "body").send_keys("foo")
def test_not_interactable_if_hidden(self):
test_html = self.marionette.absolute_url("keyboard.html")
self.marionette.navigate(test_html)
not_displayed = self.marionette.find_element(By.ID, "notDisplayed")
self.assertRaises(ElementNotInteractableException, not_displayed.send_keys, "foo")
def test_appends_to_input_text(self):
self.marionette.navigate(inline("<input>"))
el = self.marionette.find_element(By.TAG_NAME, "input")
@ -367,3 +360,12 @@ class TestTypingContent(TypingTestCase):
l.send_keys("c")
self.assertEqual("abcde",
self.marionette.execute_script("return arguments[0].value;", [l]))
class TestTypingContentLegacy(TestTypingContent):
def setUp(self):
super(TestTypingContent, self).setUp()
self.marionette.delete_session()
self.marionette.start_session({"moz:webdriverClick": False})

View File

@ -1,18 +0,0 @@
<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<!-- 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/. -->
<head>
<title>Testing Double Click</title>
</head>
<div>
<p id="one-word-div">zyxw</p>
</div>
<div>
<form>
<input type="text" id="input-field" size="80"/>
</form>
</div>
</html>

View File

@ -60,9 +60,6 @@
<option value="bar">Bar</option>
</select>
</p>
<p>
<label>hidden: <input type="text" id="notDisplayed" style="display: none"></label>
</p>
</form>
</div>

View File

@ -347,6 +347,33 @@ interaction.focusElement = function(el) {
el.focus();
};
/**
* Performs checks if <var>el</var> is keyboard-interactable.
*
* To decide if an element is keyboard-interactable various properties,
* and computed CSS styles have to be evaluated. Whereby it has to be taken
* into account that the element can be part of a container (eg. option),
* and as such the container has to be checked instead.
*
* @param {Element} el
* Element to check.
*
* @return {boolean}
* True if element is keyboard-interactable, false otherwise.
*/
interaction.isKeyboardInteractable = function(el) {
const win = getWindow(el);
// body and document element are always keyboard-interactable
if (el.localName === "body" || el === win.document.documentElement) {
return true;
}
el.focus();
return el === win.document.activeElement;
};
/**
* Appends <var>path</var> to an <tt>&lt;input type=file&gt;</tt>'s
* file list.
@ -420,10 +447,47 @@ interaction.setFormControlValue = function(el, value) {
* Sequence of keystrokes to send to the element.
* @param {boolean=} [strict=false] strict
* Enforce strict accessibility tests.
* @param {boolean=} [specCompat=false] specCompat
* Use WebDriver specification compatible interactability definition.
*/
interaction.sendKeysToElement = async function(
el, value, strict = false) {
el, value, strict = false, specCompat = false) {
const a11y = accessibility.get(strict);
if (specCompat) {
await webdriverSendKeysToElement(el, value, a11y);
} else {
await legacySendKeysToElement(el, value, a11y);
}
};
async function webdriverSendKeysToElement(el, value, a11y) {
const win = getWindow(el);
let containerEl = element.getContainer(el);
// TODO: Wait for element to be keyboard-interactible
if (!interaction.isKeyboardInteractable(containerEl)) {
throw new ElementNotInteractableError(
pprint`Element ${el} is not reachable by keyboard`);
}
let acc = await a11y.getAccessible(el, true);
a11y.assertActionable(acc, el);
interaction.focusElement(el);
if (el.type == "file") {
await interaction.uploadFile(el, value);
} else if ((el.type == "date" || el.type == "time") &&
Preferences.get("dom.forms.datetime")) {
interaction.setFormControlValue(el, value);
} else {
event.sendKeysToElement(value, el, win);
}
}
async function legacySendKeysToElement(el, value, a11y) {
const win = getWindow(el);
if (el.type == "file") {
@ -447,7 +511,7 @@ interaction.sendKeysToElement = async function(
interaction.focusElement(el);
event.sendKeysToElement(value, el, win);
}
};
}
/**
* Determine the element displayedness of an element.

View File

@ -1427,6 +1427,7 @@ async function sendKeysToElement(el, val) {
await interaction.sendKeysToElement(
el, val,
capabilities.get("moz:accessibilityChecks"),
capabilities.get("moz:webdriverClick"),
);
}

View File

@ -283674,6 +283674,11 @@
{}
]
],
"webdriver/tests/element_send_keys/__init__.py": [
[
{}
]
],
"webdriver/tests/sessions/new_session/conftest.py": [
[
{}
@ -373768,6 +373773,18 @@
{}
]
],
"webdriver/tests/element_send_keys/interactability.py": [
[
"/webdriver/tests/element_send_keys/interactability.py",
{}
]
],
"webdriver/tests/element_send_keys/scroll_into_view.py": [
[
"/webdriver/tests/element_send_keys/scroll_into_view.py",
{}
]
],
"webdriver/tests/execute_async_script/user_prompts.py": [
[
"/webdriver/tests/execute_async_script/user_prompts.py",
@ -576410,7 +576427,7 @@
"testharness"
],
"webdriver/OWNERS": [
"4986cd1bfa1e4c8e5c836581871745b5b2cc440e",
"020bcd036daed8eb8928c2924ea1d04050cf1939",
"support"
],
"webdriver/README.md": [
@ -576549,6 +576566,18 @@
"918c6e48047f31a088ec44e9b0d070b0ae3d6077",
"wdspec"
],
"webdriver/tests/element_send_keys/__init__.py": [
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
"support"
],
"webdriver/tests/element_send_keys/interactability.py": [
"bd7cd6c009c86fe93c0e132c30fb9674413962d4",
"wdspec"
],
"webdriver/tests/element_send_keys/scroll_into_view.py": [
"fb192d5d1d93aa729b07cadcadfa630587bd0b39",
"wdspec"
],
"webdriver/tests/execute_async_script/user_prompts.py": [
"e31edd4537f9b7479a348465154381f5b18f938c",
"wdspec"
@ -576674,11 +576703,11 @@
"support"
],
"webdriver/tests/support/asserts.py": [
"ae2037918aeb450a86f3615f963fe4a4032324cb",
"68bb420a9d85810c9fd8b6eaa569b855dfb83638",
"support"
],
"webdriver/tests/support/fixtures.py": [
"2331c38e8de48de41b982dee01b14cfe1092cad0",
"b9b62366cd60ae7167ad2d0efdf3790ae2e780a4",
"support"
],
"webdriver/tests/support/http_request.py": [

View File

@ -4,3 +4,4 @@
@lukeis
@mjzffr
@shs96c
@whimboo

View File

@ -0,0 +1,136 @@
from tests.support.asserts import assert_error, assert_same_element, assert_success
from tests.support.inline import iframe, inline
def send_keys_to_element(session, element, text):
return session.transport.send(
"POST",
"/session/{session_id}/element/{element_id}/value".format(
session_id=session.session_id,
element_id=element.id),
{"text": text})
def test_body_is_interactable(session):
session.url = inline("""
<body onkeypress="document.getElementById('result').value += event.key">
<input type="text" id="result"/>
</body>
""")
element = session.find.css("body", all=False)
result = session.find.css("input", all=False)
# By default body is the active element
assert_same_element(session, element, session.active_element)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert_same_element(session, element, session.active_element)
assert result.property("value") == "foo"
def test_document_element_is_interactable(session):
session.url = inline("""
<html onkeypress="document.getElementById('result').value += event.key">
<input type="text" id="result"/>
</html>
""")
body = session.find.css("body", all=False)
element = session.find.css(":root", all=False)
result = session.find.css("input", all=False)
# By default body is the active element
assert_same_element(session, body, session.active_element)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert_same_element(session, element, session.active_element)
assert result.property("value") == "foo"
def test_iframe_is_interactable(session):
session.url = inline(iframe("""
<body onkeypress="document.getElementById('result').value += event.key">
<input type="text" id="result"/>
</body>
"""))
body = session.find.css("body", all=False)
frame = session.find.css("iframe", all=False)
# By default the body has the focus
assert_same_element(session, body, session.active_element)
response = send_keys_to_element(session, frame, "foo")
assert_success(response)
assert_same_element(session, frame, session.active_element)
# Any key events are immediately routed to the nested
# browsing context's active document.
session.switch_frame(frame)
result = session.find.css("input", all=False)
assert result.property("value") == "foo"
def test_transparent_element(session):
session.url = inline("<input style=\"opacity: 0;\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert element.property("value") == "foo"
def test_readonly_element(session):
session.url = inline("<input readonly>")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert element.property("value") == ""
def test_obscured_element(session):
session.url = inline("""
<input type="text" />
<div style="position: relative; top: -3em; height: 5em; background-color: blue"></div>
""")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert element.property("value") == "foo"
def test_not_a_focusable_element(session):
session.url = inline("<div>foo</div>")
element = session.find.css("div", all=False)
response = send_keys_to_element(session, element, "foo")
assert_error(response, "element not interactable")
def test_not_displayed_element(session):
session.url = inline("<input style=\"display: none\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_error(response, "element not interactable")
def test_hidden_element(session):
session.url = inline("<input style=\"visibility: hidden\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_error(response, "element not interactable")
def test_disabled_element(session):
session.url = inline("<input disabled=\"false\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_error(response, "element not interactable")

View File

@ -0,0 +1,78 @@
from tests.support.asserts import assert_success
from tests.support.fixtures import is_element_in_viewport
from tests.support.inline import inline
def send_keys_to_element(session, element, text):
return session.transport.send(
"POST",
"/session/{session_id}/element/{element_id}/value".format(
session_id=session.session_id,
element_id=element.id),
{"text": text})
def test_element_outside_of_not_scrollable_viewport(session):
session.url = inline("<input style=\"position: relative; left: -9999px;\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert not is_element_in_viewport(session, element)
def test_element_outside_of_scrollable_viewport(session):
session.url = inline("<input style=\"margin-top: 102vh;\">")
element = session.find.css("input", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert is_element_in_viewport(session, element)
def test_option_select_container_outside_of_scrollable_viewport(session):
session.url = inline("""
<select style="margin-top: 102vh;">
<option value="foo">foo</option>
<option value="bar" id="bar">bar</option>
</select>
""")
element = session.find.css("option#bar", all=False)
select = session.find.css("select", all=False)
response = send_keys_to_element(session, element, "bar")
assert_success(response)
assert is_element_in_viewport(session, select)
assert is_element_in_viewport(session, element)
def test_option_stays_outside_of_scrollable_viewport(session):
session.url = inline("""
<select multiple style="height: 105vh; margin-top: 100vh;">
<option value="foo" id="foo" style="height: 100vh;">foo</option>
<option value="bar" id="bar" style="background-color: yellow;">bar</option>
</select>
""")
select = session.find.css("select", all=False)
option_foo = session.find.css("option#foo", all=False)
option_bar = session.find.css("option#bar", all=False)
response = send_keys_to_element(session, option_bar, "bar")
assert_success(response)
assert is_element_in_viewport(session, select)
assert is_element_in_viewport(session, option_foo)
assert not is_element_in_viewport(session, option_bar)
def test_contenteditable_element_outside_of_scrollable_viewport(session):
session.url = inline("<div contenteditable style=\"margin-top: 102vh;\"></div>")
element = session.find.css("div", all=False)
response = send_keys_to_element(session, element, "foo")
assert_success(response)
assert is_element_in_viewport(session, element)

View File

@ -1,5 +1,6 @@
from webdriver import Element, WebDriverException
# WebDriver specification ID: dfn-error-response-data
errors = {
"element click intercepted": 400,
@ -32,6 +33,7 @@ errors = {
"unsupported operation": 500,
}
# WebDriver specification ID: dfn-send-an-error
#
# > When required to send an error, with error code, a remote end must run the
@ -98,7 +100,7 @@ def assert_dialog_handled(session, expected_text):
except:
assert (result.status == 200 and
result.body["value"] != expected_text), (
"Dialog with text '%s' was not handled." % expected_text)
"Dialog with text '%s' was not handled." % expected_text)
def assert_same_element(session, a, b):
@ -123,7 +125,7 @@ def assert_same_element(session, a, b):
return
message = ("Expected element references to describe the same element, " +
"but they did not.")
"but they did not.")
# Attempt to provide more information, accounting for possible errors such
# as stale element references or not visible elements.

View File

@ -267,3 +267,19 @@ def create_dialog(session):
def clear_all_cookies(session):
"""Removes all cookies associated with the current active document"""
session.transport.send("DELETE", "session/%s/cookie" % session.session_id)
def is_element_in_viewport(session, element):
"""Check if element is outside of the viewport"""
return session.execute_script("""
let el = arguments[0];
let rect = el.getBoundingClientRect();
let viewport = {
height: window.innerHeight || document.documentElement.clientHeight,
width: window.innerWidth || document.documentElement.clientWidth,
};
return !(rect.right < 0 || rect.bottom < 0 ||
rect.left > viewport.width || rect.top > viewport.height)
""", args=(element,))