Bug 1414322 - Use WebDriver conformant interactability checks for sendKeysToElement. draft
authorHenrik Skupin <mail@hskupin.info>
Fri, 10 Nov 2017 20:29:04 +0100
changeset 705893 54ff545501bdcb8d3820596b0a0c5614722bde7b
parent 705892 e0c60eb25a743c729554e046cb578677dd9e7ddd
child 742513 dd01301637ca4b2dc6254554791e0cbf0bd549b2
push id91640
push userbmo:hskupin@gmail.com
push dateThu, 30 Nov 2017 22:25:18 +0000
bugs1414322
milestone59.0a1
Bug 1414322 - Use WebDriver conformant interactability checks for sendKeysToElement. 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
testing/geckodriver/README.md
testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
testing/marionette/harness/marionette_harness/tests/unit/test_typing.py
testing/marionette/harness/marionette_harness/www/double_click.html
testing/marionette/harness/marionette_harness/www/keyboard.html
testing/marionette/interaction.js
testing/marionette/listener.js
testing/web-platform/meta/MANIFEST.json
testing/web-platform/tests/webdriver/OWNERS
testing/web-platform/tests/webdriver/tests/element_send_keys/__init__.py
testing/web-platform/tests/webdriver/tests/element_send_keys/interactability.py
testing/web-platform/tests/webdriver/tests/element_send_keys/scroll_into_view.py
testing/web-platform/tests/webdriver/tests/support/asserts.py
testing/web-platform/tests/webdriver/tests/support/fixtures.py
--- a/testing/geckodriver/README.md
+++ b/testing/geckodriver/README.md
@@ -279,31 +279,35 @@ and run. It may contain any of the follo
    string, a boolean or an integer.
  </tr>
 </table>
 
 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
 so it's conform with the new checks. But if the problem is located in
 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
 ------------
 
 <table>
  <thead>
   <tr>
    <th>Name
--- a/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
@@ -1,19 +1,25 @@
 # 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/.
 
+import urllib
+
 from marionette_driver.by import By
 from marionette_driver.keys import Keys
 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)
         if self.marionette.session_capabilities["platformName"] == "darwin":
             self.mod_key = Keys.META
         else:
             self.mod_key = Keys.CONTROL
         self.action = Actions(self.marionette)
@@ -29,22 +35,24 @@ class TestMouseAction(MarionetteTestCase
     def test_clicking_element_out_of_view_succeeds(self):
         # The action based click doesn"t check for visibility.
         test_html = self.marionette.absolute_url("hidden.html")
         self.marionette.navigate(test_html)
         el = self.marionette.find_element(By.ID, "child")
         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"))
 
     def test_context_click_action(self):
         test_html = self.marionette.absolute_url("clicks.html")
         self.marionette.navigate(test_html)
         click_el = self.marionette.find_element(By.ID, "normal")
 
--- a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py
@@ -316,23 +316,16 @@ class TestTypingContent(TypingTestCase):
 
     def test_should_send_keys_to_elements_without_the_value_attribute(self):
         test_html = self.marionette.absolute_url("keyboard.html")
         self.marionette.navigate(test_html)
 
         # 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")
         el.send_keys("foo")
         el.send_keys("bar")
         self.assertEqual("foobar", el.get_property("value"))
 
     def test_appends_to_textarea(self):
@@ -362,8 +355,17 @@ class TestTypingContent(TypingTestCase):
         self.marionette.execute_script(
             """var el = arguments[0];
             el.selectionStart = el.selectionEnd = el.value.length / 2;""",
             script_args=[l])
 
         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})
deleted file mode 100644
--- a/testing/marionette/harness/marionette_harness/www/double_click.html
+++ /dev/null
@@ -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>
--- a/testing/marionette/harness/marionette_harness/www/keyboard.html
+++ b/testing/marionette/harness/marionette_harness/www/keyboard.html
@@ -55,19 +55,16 @@
       </label>
     </p>
     <p>
       <select id="selector" onchange="updateResult(event)">
         <option value="foo">Foo</option>
         <option value="bar">Bar</option>
       </select>
     </p>
-    <p>
-      <label>hidden: <input type="text" id="notDisplayed" style="display: none"></label>
-    </p>
   </form>
 </div>
 
 <div id="formageddon">
     <form action="#">
         Key Up: <input type="text" id="keyUp" onkeyup="javascript:updateContent(this)"/><br/>
         Key Down: <input type="text" id="keyDown" onkeydown="javascript:updateContent(this)"/><br/>
         Key Press: <input type="text" id="keyPress" onkeypress="javascript:updateContent(this)"/><br/>
--- a/testing/marionette/interaction.js
+++ b/testing/marionette/interaction.js
@@ -343,16 +343,43 @@ interaction.focusElement = function(el) 
       let len = el.value.length;
       el.setSelectionRange(len, len);
     }
   }
   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.
  *
  * @param {HTMLInputElement} el
  *     An <tt>&lt;input type=file&gt;</tt> element.
  * @param {string} path
  *     Full path to file.
  *
@@ -415,20 +442,57 @@ interaction.setFormControlValue = functi
  * Send keys to element.
  *
  * @param {DOMElement|XULElement} el
  *     Element to send key events to.
  * @param {Array.<string>} 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") {
     await interaction.uploadFile(el, value);
   } else if ((el.type == "date" || el.type == "time") &&
       Preferences.get("dom.forms.datetime")) {
     interaction.setFormControlValue(el, value);
   } else {
@@ -442,17 +506,17 @@ interaction.sendKeysToElement = async fu
     }
 
     let acc = await a11y.getAccessible(el, true);
     a11y.assertActionable(acc, el);
 
     interaction.focusElement(el);
     event.sendKeysToElement(value, el, win);
   }
-};
+}
 
 /**
  * Determine the element displayedness of an element.
  *
  * @param {DOMElement|XULElement} el
  *     Element to determine displayedness of.
  * @param {boolean=} [strict=false] strict
  *     Enforce strict accessibility tests.
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -1422,16 +1422,17 @@ function isElementSelected(el) {
   return interaction.isElementSelected(
       el, capabilities.get("moz:accessibilityChecks"));
 }
 
 async function sendKeysToElement(el, val) {
   await interaction.sendKeysToElement(
       el, val,
       capabilities.get("moz:accessibilityChecks"),
+      capabilities.get("moz:webdriverClick"),
   );
 }
 
 /** Clear the text of an element. */
 function clearElement(el) {
   try {
     if (el.type == "file") {
       el.value = null;
--- a/testing/web-platform/meta/MANIFEST.json
+++ b/testing/web-platform/meta/MANIFEST.json
@@ -283669,16 +283669,21 @@
      {}
     ]
    ],
    "webdriver/tests/element_retrieval/__init__.py": [
     [
      {}
     ]
    ],
+   "webdriver/tests/element_send_keys/__init__.py": [
+    [
+     {}
+    ]
+   ],
    "webdriver/tests/sessions/new_session/conftest.py": [
     [
      {}
     ]
    ],
    "webdriver/tests/sessions/new_session/support/__init__.py": [
     [
      {}
@@ -373763,16 +373768,28 @@
     ]
    ],
    "webdriver/tests/element_retrieval/get_active_element.py": [
     [
      "/webdriver/tests/element_retrieval/get_active_element.py",
      {}
     ]
    ],
+   "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",
      {}
     ]
    ],
    "webdriver/tests/execute_script/cyclic.py": [
     [
@@ -576405,17 +576422,17 @@
    "26fbc55b0c313be854ddd59469baf6dcdd5d21c6",
    "testharness"
   ],
   "webauthn/makecredential-badargs-cryptoparameters.https.html": [
    "9e2cbb2a667cf57f979c3e67516fb63fedd18d46",
    "testharness"
   ],
   "webdriver/OWNERS": [
-   "4986cd1bfa1e4c8e5c836581871745b5b2cc440e",
+   "020bcd036daed8eb8928c2924ea1d04050cf1939",
    "support"
   ],
   "webdriver/README.md": [
    "185acb69e9516e0564e16bf7d7f8dc2a4c48d3c7",
    "support"
   ],
   "webdriver/tests/__init__.py": [
    "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@@ -576544,16 +576561,28 @@
   "webdriver/tests/element_retrieval/find_elements.py": [
    "2d5c3c98b00e21a36f91e5797bb97835a8b63f2e",
    "wdspec"
   ],
   "webdriver/tests/element_retrieval/get_active_element.py": [
    "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"
   ],
   "webdriver/tests/execute_script/cyclic.py": [
    "cbebfbd2413ea0b10f547ab66fcc7159898e684a",
    "wdspec"
   ],
@@ -576669,21 +576698,21 @@
    "570274d59020c4d8d0b8ecd604660ee7d710a165",
    "wdspec"
   ],
   "webdriver/tests/support/__init__.py": [
    "5a31a3917a5157516c10951a3b3d5ffb43b992d9",
    "support"
   ],
   "webdriver/tests/support/asserts.py": [
-   "ae2037918aeb450a86f3615f963fe4a4032324cb",
+   "68bb420a9d85810c9fd8b6eaa569b855dfb83638",
    "support"
   ],
   "webdriver/tests/support/fixtures.py": [
-   "2331c38e8de48de41b982dee01b14cfe1092cad0",
+   "b9b62366cd60ae7167ad2d0efdf3790ae2e780a4",
    "support"
   ],
   "webdriver/tests/support/http_request.py": [
    "cb40c781fea2280b98135522def5e6a116d7b946",
    "support"
   ],
   "webdriver/tests/support/inline.py": [
    "ffabd6a12d6e7928176fa00702214e0c8e0a25d7",
--- a/testing/web-platform/tests/webdriver/OWNERS
+++ b/testing/web-platform/tests/webdriver/OWNERS
@@ -1,6 +1,7 @@
 @AutomatedTester
 @JKereliuk
 @andreastt
 @lukeis
 @mjzffr
 @shs96c
+@whimboo
new file mode 100644
new file mode 100644
--- /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("""
+        <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")
new file mode 100644
--- /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("<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)
--- a/testing/web-platform/tests/webdriver/tests/support/asserts.py
+++ b/testing/web-platform/tests/webdriver/tests/support/asserts.py
@@ -1,10 +1,11 @@
 from webdriver import Element, WebDriverException
 
+
 # WebDriver specification ID: dfn-error-response-data
 errors = {
     "element click intercepted": 400,
     "element not selectable": 400,
     "element not interactable": 400,
     "insecure certificate": 400,
     "invalid argument": 400,
     "invalid cookie domain": 400,
@@ -27,16 +28,17 @@ errors = {
     "unable to capture screen": 500,
     "unexpected alert open": 500,
     "unknown command": 404,
     "unknown error": 500,
     "unknown method": 405,
     "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
 # > following steps:
 # >
 # > 1. Let http status and name be the error response data for error code.
 # > 2. Let message be an implementation-defined string containing a
 # >    human-readable description of the reason for the error.
@@ -93,17 +95,17 @@ def assert_dialog_handled(session, expec
     # fixture's dialog, then the "Get Alert Text" command will return
     # successfully. In that case, the text must be different than that
     # of this fixture's dialog.
     try:
         assert_error(result, "no such alert")
     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):
     """Verify that two element references describe the same element."""
     if isinstance(a, dict):
         assert Element.identifier in a, "Actual value does not describe an element"
         a_id = a[Element.identifier]
     elif isinstance(a, Element):
@@ -118,17 +120,17 @@ def assert_same_element(session, a, b):
         b_id = b.id
     else:
         raise AssertionError("Expected value is not a dictionary or web element")
 
     if a_id == b_id:
         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.
     try:
         a_markup = session.execute_script("return arguments[0].outerHTML;", args=(a,))
         b_markup = session.execute_script("return arguments[0].outerHTML;", args=(b,))
         message += " Actual: `%s`. Expected: `%s`." % (a_markup, b_markup)
     except WebDriverException:
--- a/testing/web-platform/tests/webdriver/tests/support/fixtures.py
+++ b/testing/web-platform/tests/webdriver/tests/support/fixtures.py
@@ -262,8 +262,24 @@ def create_dialog(session):
              ignored_exceptions=webdriver.NoSuchAlertException)
 
     return create_dialog
 
 
 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,))