Automated Keyboard Input (#10113)

diff --git a/docs/_writing-tests/testdriver.md b/docs/_writing-tests/testdriver.md
index 3f5787f..5508dba 100644
--- a/docs/_writing-tests/testdriver.md
+++ b/docs/_writing-tests/testdriver.md
@@ -19,6 +19,7 @@
 context (and not therefore in any frame or window opened from it).
 
 ### `test_driver.click(element)`
+#### `element: a DOM Element object`
 
 This function causes a click to occur on the target element (an
 `Element` object), potentially scrolling the document to make it
@@ -30,5 +31,19 @@
 document must not have any DOM mutations made between the function
 being called and the promise settling.
 
+### `test_driver.send_keys(element, keys)`
+#### `element: a DOM Element object`
+#### `keys: string to send to the element`
+
+This function causes the string `keys` to be send to the target
+element (an `Element` object), potentially scrolling the document to
+make it possible to send keys. It returns a `Promise` that resolves
+after the keys have been send or rejects if the keys cannot be sent
+to the element.
+
+Note that if the element that's keys need to be send to does not have
+a unique ID, the document must not have any DOM mutations made
+between the function being called and the promise settling.
+
 
 [testharness]: {{ site.baseurl }}{% link _writing-tests/testharness.md %}
diff --git a/html/editing/focus/focus-01-manual.html b/html/editing/focus/focus-01.html
similarity index 86%
rename from html/editing/focus/focus-01-manual.html
rename to html/editing/focus/focus-01.html
index 16e0b0f..331ff53 100644
--- a/html/editing/focus/focus-01-manual.html
+++ b/html/editing/focus/focus-01.html
@@ -7,12 +7,8 @@
 <meta assert="assert" content="Check if the key events received by document are targeted at the element when it is focused">
 <script src="/resources/testharness.js"></script>
 <script src="/resources/testharnessreport.js"></script>
-<h2>Steps:</h2>
-<ol>
-  <li>Input any character into the textbox by keyboard in 10 seconds.</li>
-</ol>
-<h2>Expect results:</h2>
-<p>PASS</p>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
 <div id="log"></div>
 <input id="test">
 <script>
@@ -40,4 +36,10 @@
   assert_equals(evt.target, testEle, "The keyup events must be targeted at the input element.");
 });
 
+var input_element = document.getElementById("test");
+
+t1.step(function() {
+  test_driver.send_keys(input_element, "a");
+});
+
 </script>
diff --git a/infrastructure/testdriver/send_keys.html b/infrastructure/testdriver/send_keys.html
new file mode 100644
index 0000000..2170347
--- /dev/null
+++ b/infrastructure/testdriver/send_keys.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>TestDriver send keys method</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<input type="text" id="text">Text Input</button>
+
+<script>
+async_test(t => {
+  let input_text = "Hello, wpt!";
+  let text_box = document.getElementById("text");
+  test_driver
+    .send_keys(text_box, input_text)
+    .then(() => {
+      assert_true(text_box.value == input_text);
+      t.done();
+    })
+    .catch(t.unreached_func("send keys failed"));
+});
+</script>
diff --git a/resources/testdriver.js b/resources/testdriver.js
index a6aa298..a09a6e8 100644
--- a/resources/testdriver.js
+++ b/resources/testdriver.js
@@ -83,6 +83,42 @@
             return window.test_driver_internal.click(element,
                                                      {x: centerPoint[0],
                                                       y: centerPoint[1]});
+        },
+
+        /**
+         * Send keys to an element
+         *
+         * This matches the behaviour of the {@link
+         * https://w3c.github.io/webdriver/webdriver-spec.html#element-send-keys|WebDriver
+         * Send Keys command}.
+         *
+         * @param {Element} element - element to send keys to
+         * @param {String} keys - keys to send to the element
+         * @returns {Promise} fulfilled after keys are sent, or rejected in
+         *                    the cases the WebDriver command errors
+         */
+        send_keys: function(element, keys) {
+            if (window.top !== window) {
+                return Promise.reject(new Error("can only send keys in top-level window"));
+            }
+
+            if (!window.document.contains(element)) {
+                return Promise.reject(new Error("element in different document or shadow tree"));
+            }
+
+            if (!inView(element)) {
+                element.scrollIntoView({behavior: "instant",
+                                        block: "end",
+                                        inline: "nearest"});
+            }
+
+            var pointerInteractablePaintTree = getPointerInteractablePaintTree(element);
+            if (pointerInteractablePaintTree.length === 0 ||
+                !element.contains(pointerInteractablePaintTree[0])) {
+                return Promise.reject(new Error("element send_keys intercepted error"));
+            }
+
+            return window.test_driver_internal.send_keys(element, keys);
         }
     };
 
@@ -96,6 +132,17 @@
          */
         click: function(element, coords) {
             return Promise.reject(new Error("unimplemented"));
+        },
+
+        /**
+         * Triggers a user-initated click
+         *
+         * @param {Element} element - element to be clicked
+         * @param {String} keys - keys to send to the element
+         * @returns {Promise} fulfilled after keys are sent or rejected if click fails
+         */
+        send_keys: function(element, keys) {
+            return Promise.reject(new Error("unimplemented"));
         }
     };
 })();
diff --git a/tools/wptrunner/wptrunner/executors/base.py b/tools/wptrunner/wptrunner/executors/base.py
index fbb75d6..80549af 100644
--- a/tools/wptrunner/wptrunner/executors/base.py
+++ b/tools/wptrunner/wptrunner/executors/base.py
@@ -501,7 +501,8 @@
         }
 
         self.actions = {
-            "click": ClickAction(self.logger, self.protocol)
+            "click": ClickAction(self.logger, self.protocol),
+            "send_keys": SendKeysAction(self.logger, self.protocol)
         }
 
     def __call__(self, result):
@@ -544,7 +545,6 @@
     def _send_message(self, message_type, status, message=None):
         self.protocol.testdriver.send_message(message_type, status, message=message)
 
-
 class ClickAction(object):
     def __init__(self, logger, protocol):
         self.logger = logger
@@ -559,3 +559,19 @@
             raise ValueError("Selector matches multiple elements")
         self.logger.debug("Clicking element: %s" % selector)
         self.protocol.click.element(elements[0])
+
+class SendKeysAction(object):
+    def __init__(self, logger, protocol):
+        self.logger = logger
+        self.protocol = protocol
+
+    def __call__(self, payload):
+        selector = payload["selector"]
+        keys = payload["keys"]
+        elements = self.protocol.select.elements_by_selector(selector)
+        if len(elements) == 0:
+            raise ValueError("Selector matches no elements")
+        elif len(elements) > 1:
+            raise ValueError("Selector matches multiple elements")
+        self.logger.debug("Sending keys to element: %s" % selector)
+        self.protocol.send_keys.send_keys(elements[0], keys)
diff --git a/tools/wptrunner/wptrunner/executors/executormarionette.py b/tools/wptrunner/wptrunner/executors/executormarionette.py
index 1c49ab2..60332a6 100644
--- a/tools/wptrunner/wptrunner/executors/executormarionette.py
+++ b/tools/wptrunner/wptrunner/executors/executormarionette.py
@@ -32,6 +32,7 @@
                        StorageProtocolPart,
                        SelectorProtocolPart,
                        ClickProtocolPart,
+                       SendKeysProtocolPart,
                        TestDriverProtocolPart)
 from ..testrunner import Stop
 from ..webdriver_server import GeckoDriverServer
@@ -307,6 +308,12 @@
     def element(self, element):
         return element.click()
 
+class MarionetteSendKeysProtocolPart(SendKeysProtocolPart):
+    def setup(self):
+        self.marionette = self.parent.marionette
+
+    def send_keys(self, element, keys):
+        return element.send_keys(keys)
 
 class MarionetteTestDriverProtocolPart(TestDriverProtocolPart):
     def setup(self):
@@ -329,6 +336,7 @@
                   MarionetteStorageProtocolPart,
                   MarionetteSelectorProtocolPart,
                   MarionetteClickProtocolPart,
+                  MarionetteSendKeysProtocolPart,
                   MarionetteTestDriverProtocolPart]
 
     def __init__(self, executor, browser, capabilities=None, timeout_multiplier=1):
diff --git a/tools/wptrunner/wptrunner/executors/executorselenium.py b/tools/wptrunner/wptrunner/executors/executorselenium.py
index 0b71189..94ef10b 100644
--- a/tools/wptrunner/wptrunner/executors/executorselenium.py
+++ b/tools/wptrunner/wptrunner/executors/executorselenium.py
@@ -19,6 +19,7 @@
                        Protocol,
                        SelectorProtocolPart,
                        ClickProtocolPart,
+                       SendKeysProtocolPart,
                        TestDriverProtocolPart)
 from ..testrunner import Stop
 
@@ -134,6 +135,13 @@
     def element(self, element):
         return element.click()
 
+class SeleniumSendKeysProtocolPart(SendKeysProtocolPart):
+    def setup(self):
+        self.webdriver = self.parent.webdriver
+
+    def send_keys(self, element, keys):
+        return element.send_keys(keys)
+
 
 class SeleniumTestDriverProtocolPart(TestDriverProtocolPart):
     def setup(self):
@@ -154,6 +162,7 @@
                   SeleniumTestharnessProtocolPart,
                   SeleniumSelectorProtocolPart,
                   SeleniumClickProtocolPart,
+                  SeleniumSendKeysProtocolPart,
                   SeleniumTestDriverProtocolPart]
 
     def __init__(self, executor, browser, capabilities, **kwargs):
diff --git a/tools/wptrunner/wptrunner/executors/protocol.py b/tools/wptrunner/wptrunner/executors/protocol.py
index dc69d24..3c938f0 100644
--- a/tools/wptrunner/wptrunner/executors/protocol.py
+++ b/tools/wptrunner/wptrunner/executors/protocol.py
@@ -259,6 +259,20 @@
         :param element: A protocol-specific handle to an element."""
         pass
 
+class SendKeysProtocolPart(ProtocolPart):
+    """Protocol part for performing trusted clicks"""
+    __metaclass__ = ABCMeta
+
+    name = "send_keys"
+
+    @abstractmethod
+    def send_keys(self, element, keys):
+        """Send keys to a specific element.
+
+        :param element: A protocol-specific handle to an element.
+        :param keys: A protocol-specific handle to a string of input keys."""
+        pass
+
 
 class TestDriverProtocolPart(ProtocolPart):
     """Protocol part that implements the basic functionality required for
diff --git a/tools/wptrunner/wptrunner/testdriver-extra.js b/tools/wptrunner/wptrunner/testdriver-extra.js
index 856a33e..ef962d3 100644
--- a/tools/wptrunner/wptrunner/testdriver-extra.js
+++ b/tools/wptrunner/wptrunner/testdriver-extra.js
@@ -60,4 +60,14 @@
         window.opener.postMessage({"type": "action", "action": "click", "selector": selector}, "*");
         return pending_promise;
     };
+
+    window.test_driver_internal.send_keys = function(element, keys) {
+        const selector = get_selector(element);
+        const pending_promise = new Promise(function(resolve, reject) {
+            pending_resolve = resolve;
+            pending_reject = reject;
+        });
+        window.opener.postMessage({"type": "action", "action": "send_keys", "selector": selector, "keys": keys}, "*");
+        return pending_promise;
+    };
 })();