From cb6d8c72b5a7b7b43f3d45b14edbbfa466f0be4d Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Fri, 10 Nov 2017 20:29:04 +0100 Subject: [PATCH] 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 --- testing/geckodriver/README.md | 10 +- .../tests/unit/test_mouse_action.py | 16 ++- .../tests/unit/test_typing.py | 16 ++- .../marionette_harness/www/double_click.html | 18 --- .../marionette_harness/www/keyboard.html | 3 - testing/marionette/interaction.js | 68 ++++++++- testing/marionette/listener.js | 1 + testing/web-platform/meta/MANIFEST.json | 35 ++++- testing/web-platform/tests/webdriver/OWNERS | 1 + .../tests/element_send_keys/__init__.py | 0 .../element_send_keys/interactability.py | 136 ++++++++++++++++++ .../element_send_keys/scroll_into_view.py | 78 ++++++++++ .../tests/webdriver/tests/support/asserts.py | 6 +- .../tests/webdriver/tests/support/fixtures.py | 16 +++ 14 files changed, 362 insertions(+), 42 deletions(-) delete mode 100644 testing/marionette/harness/marionette_harness/www/double_click.html create mode 100644 testing/web-platform/tests/webdriver/tests/element_send_keys/__init__.py create mode 100644 testing/web-platform/tests/webdriver/tests/element_send_keys/interactability.py create mode 100644 testing/web-platform/tests/webdriver/tests/element_send_keys/scroll_into_view.py diff --git a/testing/geckodriver/README.md b/testing/geckodriver/README.md index 1498120aa281..f2346fa572d3 100644 --- a/testing/geckodriver/README.md +++ b/testing/geckodriver/README.md @@ -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 ------------ diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py index 3081ad97e5a6..1c3bff7173a4 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py @@ -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(""" +
zyxw
+ """)) + + 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")) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py index fee4bb1ee73b..e1e7262d24cf 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py @@ -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("")) 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}) diff --git a/testing/marionette/harness/marionette_harness/www/double_click.html b/testing/marionette/harness/marionette_harness/www/double_click.html deleted file mode 100644 index fb3ec217a6a2..000000000000 --- a/testing/marionette/harness/marionette_harness/www/double_click.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - Testing Double Click - -
-

zyxw

-
- -
-
- -
-
- diff --git a/testing/marionette/harness/marionette_harness/www/keyboard.html b/testing/marionette/harness/marionette_harness/www/keyboard.html index d995b51ac6e0..9c26540917a2 100644 --- a/testing/marionette/harness/marionette_harness/www/keyboard.html +++ b/testing/marionette/harness/marionette_harness/www/keyboard.html @@ -60,9 +60,6 @@

-

- -

diff --git a/testing/marionette/interaction.js b/testing/marionette/interaction.js index beb0202dacb4..39a120725475 100644 --- a/testing/marionette/interaction.js +++ b/testing/marionette/interaction.js @@ -347,6 +347,33 @@ interaction.focusElement = function(el) { el.focus(); }; +/** + * Performs checks if el 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 path to an <input type=file>'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. diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js index 7d8c3ac971f0..07a6938d3777 100644 --- a/testing/marionette/listener.js +++ b/testing/marionette/listener.js @@ -1427,6 +1427,7 @@ async function sendKeysToElement(el, val) { await interaction.sendKeysToElement( el, val, capabilities.get("moz:accessibilityChecks"), + capabilities.get("moz:webdriverClick"), ); } diff --git a/testing/web-platform/meta/MANIFEST.json b/testing/web-platform/meta/MANIFEST.json index e8e8128f9361..637f0558ac81 100644 --- a/testing/web-platform/meta/MANIFEST.json +++ b/testing/web-platform/meta/MANIFEST.json @@ -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": [ diff --git a/testing/web-platform/tests/webdriver/OWNERS b/testing/web-platform/tests/webdriver/OWNERS index 07296e312b35..c004c0467e02 100644 --- a/testing/web-platform/tests/webdriver/OWNERS +++ b/testing/web-platform/tests/webdriver/OWNERS @@ -4,3 +4,4 @@ @lukeis @mjzffr @shs96c +@whimboo diff --git a/testing/web-platform/tests/webdriver/tests/element_send_keys/__init__.py b/testing/web-platform/tests/webdriver/tests/element_send_keys/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/testing/web-platform/tests/webdriver/tests/element_send_keys/interactability.py b/testing/web-platform/tests/webdriver/tests/element_send_keys/interactability.py new file mode 100644 index 000000000000..5812f2b305fa --- /dev/null +++ b/testing/web-platform/tests/webdriver/tests/element_send_keys/interactability.py @@ -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(""" + + + + """) + + 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(""" + + + + """) + + 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 = 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("") + 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("") + 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(""" + +
+ """) + 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("
foo
") + 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("") + 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("") + 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("") + element = session.find.css("input", all=False) + + response = send_keys_to_element(session, element, "foo") + assert_error(response, "element not interactable") diff --git a/testing/web-platform/tests/webdriver/tests/element_send_keys/scroll_into_view.py b/testing/web-platform/tests/webdriver/tests/element_send_keys/scroll_into_view.py new file mode 100644 index 000000000000..a1d454d1fe23 --- /dev/null +++ b/testing/web-platform/tests/webdriver/tests/element_send_keys/scroll_into_view.py @@ -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("") + 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("") + 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(""" + + """) + 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 = 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("
") + 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) diff --git a/testing/web-platform/tests/webdriver/tests/support/asserts.py b/testing/web-platform/tests/webdriver/tests/support/asserts.py index 41747911351f..95859ac3d2b5 100644 --- a/testing/web-platform/tests/webdriver/tests/support/asserts.py +++ b/testing/web-platform/tests/webdriver/tests/support/asserts.py @@ -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. diff --git a/testing/web-platform/tests/webdriver/tests/support/fixtures.py b/testing/web-platform/tests/webdriver/tests/support/fixtures.py index 8da0d5cc09f9..844ae039e571 100644 --- a/testing/web-platform/tests/webdriver/tests/support/fixtures.py +++ b/testing/web-platform/tests/webdriver/tests/support/fixtures.py @@ -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,))