Bug 1486883 [wpt PR 12726] - [test_driver] Add WebDriver-style actions support, a=testonly
authorNavid Zolghadr <nzolghadr@chromium.org>
Thu, 11 Oct 2018 09:31:33 +0000
changeset 489258 5100c6c46f28bbafc299040ca39708da6041c872
parent 489257 9feefc4d63b2edb086ac4f3fb49338aa8fc07b97
child 489259 ddb8b080f0b435ea622b4a01bee5cc0c6e12058d
push id247
push userfmarier@mozilla.com
push dateSat, 27 Oct 2018 01:06:44 +0000
reviewerstestonly
bugs1486883, 12726
milestone64.0a1
Bug 1486883 [wpt PR 12726] - [test_driver] Add WebDriver-style actions support, a=testonly Automatic update from web-platform-testsAdd action sequence injection in test_driver Following web driver spec for pointer action sequence and key actions this change adds this API to the test_driver interface. Use action_sequence instead of pointer_action_sequence -- [testdriver] Support sending actions sequences over WebDriver -- [testdriver] Add a builder object for constructing actions Action sequences are not really sensible to construct by hand, so this adds a builder object for constructing sequences of actions. To use it the following script mut be included after testdriver.js: <script src="/resources/testdriver-actions.js"></script> Then actions sequences can be constructed like // Drag the mouse from 0,0 to 100,100 with ctrl held down test_driver.Actions() .pointerMove(0, 0, origin="viewport") .keyDown("\uE009") .pointerDown() .pointerMove(100, 100, origin="viewport") .pointerUp() .keyUp() .send(); -- Fix error handling for testdriver tests. Otherwise a protocol error leads to FAIL not ERROR. -- Add element_by_selector to SelectorProtocolPart -- wpt-commits: ceadf71f63047e88dddd9308fd9f06db24ecfb29, f1db6b22cf40d6b56665225dfd4f83bbddd76686, 01dfc0f42edacd529749fcf543e16431ba7a9490, 723dcaffe979639976dee773c09eb24694625c23, a9504407fb46702dbb2bba7df9d349d8eba7ce55 wpt-pr: 12726
testing/web-platform/tests/docs/_writing-tests/testdriver.md
testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/elementPosition.html.ini
testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/elementTiming.html.ini
testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/eventOrder.html.ini
testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/multiDevice.html.ini
testing/web-platform/tests/infrastructure/testdriver/actions/elementPosition.html
testing/web-platform/tests/infrastructure/testdriver/actions/elementTiming.html
testing/web-platform/tests/infrastructure/testdriver/actions/eventOrder.html
testing/web-platform/tests/infrastructure/testdriver/actions/multiDevice.html
testing/web-platform/tests/resources/testdriver-actions.js
testing/web-platform/tests/resources/testdriver.js
testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py
testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py
testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py
testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py
testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py
testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js
--- a/testing/web-platform/tests/docs/_writing-tests/testdriver.md
+++ b/testing/web-platform/tests/docs/_writing-tests/testdriver.md
@@ -75,10 +75,18 @@ 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.
 
 To send special keys, one must send the respective key's codepoint. Since this uses the WebDriver protocol, you can find a [list for code points to special keys in the spec](https://w3c.github.io/webdriver/webdriver-spec.html#keyboard-actions).
 For example, to send the tab key you would send "\uE004".
 
+### `test_driver.action_sequence(actions)`
+ - `actions` <[Array]<[Object]>> an array of Action objects`
+  - `action` <[Object]> A single action. See [spec](https://www.w3.org/TR/webdriver/#actions) for format
+
+This function causes a sequence of actions to be sent to the browser. It is based of the [WebDriver API](https://www.w3.org/TR/webdriver/#actions).
+The action can be a keyboard action, a pointer action or a pause. It returns a `Promise` that
+resolves after the actions have been sent or rejects if an error was thrown.
+
 [activation]: https://html.spec.whatwg.org/multipage/interaction.html#activation
 [testharness]: {{ site.baseurl }}{% link _writing-tests/testharness.md %}
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/elementPosition.html.ini
@@ -0,0 +1,3 @@
+[elementPosition.html]
+  expected:
+    if product == "chrome" or product == "chrome_webdriver": ERROR
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/elementTiming.html.ini
@@ -0,0 +1,3 @@
+[elementTiming.html]
+  expected:
+    if product == "chrome" or product == "chrome_webdriver": ERROR
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/eventOrder.html.ini
@@ -0,0 +1,3 @@
+[eventOrder.html]
+  expected:
+    if product == "chrome" or product == "chrome_webdriver": ERROR
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/metadata/infrastructure/testdriver/actions/multiDevice.html.ini
@@ -0,0 +1,3 @@
+[multiDevice.html]
+  expected:
+    if product == "chrome" or product == "chrome_webdriver": ERROR
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/testdriver/actions/elementPosition.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>TestDriver actions: element position</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<style>
+div#test {
+  position: fixed;
+  left: -100px;
+  top: -25px;
+  width: 200px;
+  height: 75px;
+  background-color:blue;
+}
+</style>
+
+<div id="test">
+</div>
+
+<script>
+let events = [];
+
+async_test(t => {
+  let test = document.getElementById("test");
+  test.addEventListener("click", e => {
+    events.push(e.clientX);
+    events.push(e.clientY)
+  });
+
+  let div = document.getElementById("test");
+  let actions = new test_driver.Actions()
+    .pointerMove(0, 0, {origin: test})
+    .pointerDown()
+    .pointerUp()
+    .send()
+    .then(t.step_func_done(() => assert_array_equals(events, [50, 25])))
+    .catch(e => t.step_func(() => assert_unreached("Actions sequence failed " + e)));
+});
+</script>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/testdriver/actions/elementTiming.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>TestDriver actions: element timing</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<style>
+div#test1, div#test2 {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100px;
+  height: 100px;
+  background-color: blue;
+}
+
+div#test2 {
+  display: none;
+  left: -100px;
+  background-color: green;
+}
+</style>
+
+<div id="test1">
+</div>
+
+<div id="test2">
+</div>
+
+<script>
+let events = [];
+
+async_test(t => {
+  let test1 = document.getElementById("test1");
+  let test2 = document.getElementById("test2");
+  document.getElementById("test1").addEventListener("click",
+    () => {test2.style.display = "block"; test2.style.top = "100px"; test2.style.left = "0"});
+  document.getElementById("test2").addEventListener("click",
+    e => {events.push(e.clientX); events.push(e.clientY)});
+
+  let div = document.getElementById("backing");
+  let actions = new test_driver.Actions()
+    .pointerMove(0, 0, {origin: test1})
+    .pointerDown()
+    .pointerUp()
+    .pointerMove(0, 0, {origin: test2})
+    .pointerDown()
+    .pointerUp()
+    .send()
+    .then(t.step_func_done(() => assert_array_equals(events, [50, 150])))
+    .catch(e => t.step_func(() => assert_unreached("Actions sequence failed " + e)));
+});
+</script>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/testdriver/actions/eventOrder.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>TestDriver actions: event order</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<button id="a">Button a</button>
+<button id="b">Button b</button>
+
+<script>
+// Pointer 1 is added before Pointer 2 so it comes first in the list of sources
+// Therefore its actions happen first
+let events = [];
+
+async_test(t => {
+  Array.prototype.forEach.call(document.getElementsByTagName("button"),
+                               (x) => x.addEventListener("mousedown", () => {events.push(x.id)}));
+
+  let button_a = document.getElementById("a");
+  let button_b = document.getElementById("b");
+  let actions = new test_driver.Actions()
+    .addPointer("pointer1")
+    .addPointer("pointer2")
+    .pointerMove(0, 0, {origin: button_a, sourceName: "pointer1"})
+    .pointerMove(0, 0, {origin: button_b, sourceName: "pointer2"})
+    .pointerDown({sourceName: "pointer2"})
+    .pointerDown({sourceName: "pointer1"})
+    .pointerUp({sourceName: "pointer2"})
+    .pointerUp({sourceName: "pointer1"})
+    .send()
+    .then(t.step_func_done(() => assert_array_equals(events, ["a", "b"])))
+    .catch(e => t.step_func(() => assert_unreached("Actions sequence failed " + e)));
+});
+</script>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/infrastructure/testdriver/actions/multiDevice.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>TestDriver actions: multiple devices</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<input type="text" id="text"></input>
+
+<script>
+async_test(t => {
+  let text_box = document.getElementById("text");
+  let actions = new test_driver.Actions()
+    .pointerMove(0, 0, {origin: text_box})
+    .pointerDown()
+    .pointerUp()
+    .addTick()
+    .keyDown("p")
+    .keyUp("p")
+    .keyDown("a")
+    .keyUp("a")
+    .keyDown("s")
+    .keyUp("s")
+    .keyDown("s")
+    .keyUp("s");
+
+  actions.send()
+    .then(() => {
+      assert_true(text_box.value == "pass");
+      t.done();
+    })
+    .catch(t.unreached_func("Actions sequence failed"));
+});
+</script>
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/resources/testdriver-actions.js
@@ -0,0 +1,391 @@
+(function() {
+  let sourceNameIdx = 0;
+
+  /**
+   * Builder for creating a sequence of actions
+   */
+  function Actions() {
+    this.sourceTypes = new Map([["key", KeySource],
+                                ["pointer", PointerSource],
+                                ["general", GeneralSource]]);
+    this.sources = new Map();
+    this.sourceOrder = [];
+    for (let sourceType of this.sourceTypes.keys()) {
+      this.sources.set(sourceType, new Map());
+    }
+    this.currentSources = new Map();
+    for (let sourceType of this.sourceTypes.keys()) {
+      this.currentSources.set(sourceType, null);
+    }
+    this.createSource("general");
+    this.tickIdx = 0;
+  }
+
+  Actions.prototype = {
+    /**
+     * Generate the action sequence suitable for passing to
+     * test_driver.action_sequence
+     *
+     * @returns {Array} Array of WebDriver-compatible actions sequences
+     */
+    serialize: function() {
+      let actions = [];
+      for (let [sourceType, sourceName] of this.sourceOrder) {
+        let source = this.sources.get(sourceType).get(sourceName);
+        let serialized = source.serialize(this.tickIdx + 1);
+        if (serialized) {
+          serialized.id = sourceName;
+          actions.push(serialized);
+        }
+      }
+      return actions;
+    },
+
+    /**
+     * Generate and send the action sequence
+     *
+     * @returns {Promise} fulfilled after the sequence is executed,
+     *                    rejected if any actions fail.
+     */
+    send: function() {
+      let actions;
+      try {
+        actions = this.serialize();
+      } catch(e) {
+        return Promise.reject(e);
+      }
+      return test_driver.action_sequence(actions);
+    },
+
+    /**
+     * Get the action source with a particular source type and name.
+     * If no name is passed, a new source with the given type is
+     * created.
+     *
+     * @param {String} type - Source type ('general', 'key', or 'pointer')
+     * @param {String?} name - Name of the source
+     * @returns {Source} Source object for that source.
+     */
+    getSource: function(type, name) {
+      if (!this.sources.has(type)) {
+        throw new Error(`${type} is not a valid action type`);
+      }
+      if (name === null || name === undefined) {
+        name = this.currentSources.get(type);
+      }
+      if (name === null || name === undefined) {
+        return this.createSource(type, null);
+      }
+      return this.sources.get(type).get(name);
+    },
+
+    setSource: function(type, name) {
+      if (!this.sources.has(type)) {
+        throw new Error(`${type} is not a valid action type`);
+      }
+      if (!this.sources.get(type).has(name)) {
+        throw new Error(`${name} is not a valid source for ${type}`);
+      }
+      this.currentSources.set(type, name);
+      return this;
+    },
+
+    /**
+     * Add a new key input source with the given name
+     *
+     * @param {String} name - Name of the key source
+     * @param {Bool} set - Set source as the default key source
+     * @returns {Actions}
+     */
+    addKeyboard: function(name, set=true) {
+      this.createSource("key", name, true);
+      if (set) {
+        this.setKeyboard(name);
+      }
+      return this;
+    },
+
+    /**
+     * Set the current default key source
+     *
+     * @param {String} name - Name of the key source
+     * @returns {Actions}
+     */
+    setKeyboard: function(name) {
+      this.setSource("key", name);
+      return this;
+    },
+
+    /**
+     * Add a new pointer input source with the given name
+     *
+     * @param {String} type - Name of the key source
+     * @param {String} pointerType - Type of pointing device
+     * @param {Bool} set - Set source as the default key source
+     * @returns {Actions}
+     */
+    addPointer: function(name, pointerType="mouse", set=true) {
+      this.createSource("pointer", name, true, {pointerType: pointerType});
+      if (set) {
+        this.setPointer(name);
+      }
+      return this;
+    },
+
+    /**
+     * Set the current default pointer source
+     *
+     * @param {String} name - Name of the pointer source
+     * @returns {Actions}
+     */
+    setPointer: function(name) {
+      this.setSource("pointer", name);
+      return this;
+    },
+
+    createSource: function(type, name, parameters={}) {
+      if (!this.sources.has(type)) {
+        throw new Error(`${type} is not a valid action type`);
+      }
+      let sourceNames = new Set();
+      for (let [_, name] of this.sourceOrder) {
+        sourceNames.add(name);
+      }
+      if (!name) {
+        do {
+          name = "" + sourceNameIdx++;
+        } while (sourceNames.has(name))
+      } else {
+        if (sourceNames.has(name)) {
+          throw new Error(`Alreay have a source of type ${type} named ${name}.`);
+        }
+      }
+      this.sources.get(type).set(name, new (this.sourceTypes.get(type))(parameters));
+      this.currentSources.set(type, name);
+      this.sourceOrder.push([type, name]);
+      return this.sources.get(type).get(name);
+    },
+
+    /**
+     * Insert a new actions tick
+     *
+     * @param {Number?} duration - Minimum length of the tick in ms.
+     * @returns {Actions}
+     */
+    addTick: function(duration) {
+      this.tickIdx += 1;
+      if (duration) {
+        this.pause(duration);
+      }
+      return this;
+    },
+
+    /**
+     * Add a pause to the current tick
+     *
+     * @param {Number?} duration - Minimum length of the tick in ms.
+     * @returns {Actions}
+     */
+    pause: function(duration) {
+      this.getSource("general").addPause(this, duration);
+      return this;
+    },
+
+    /**
+     * Create a keyDown event for the current default key source
+     *
+     * @param {String} key - Key to press
+     * @param {String?} sourceName - Named key source to use or null for the default key source
+     * @returns {Actions}
+     */
+    keyDown: function(key, {sourceName=null}={}) {
+      let source = this.getSource("key", sourceName);
+      source.keyDown(this, key);
+      return this;
+    },
+
+    /**
+     * Create a keyDown event for the current default key source
+     *
+     * @param {String} key - Key to release
+     * @param {String?} sourceName - Named key source to use or null for the default key source
+     * @returns {Actions}
+     */
+    keyUp: function(key, {sourceName=null}={}) {
+      let source = this.getSource("key", sourceName);
+      source.keyUp(this, key);
+      return this;
+    },
+
+    /**
+     * Create a pointerDown event for the current default pointer source
+     *
+     * @param {String} button - Button to press
+     * @param {String?} sourceName - Named pointer source to use or null for the default
+     *                               pointer source
+     * @returns {Actions}
+     */
+    pointerDown: function({button=0, sourceName=null}={}) {
+      let source = this.getSource("pointer", sourceName);
+      source.pointerDown(this, button);
+      return this;
+    },
+
+    /**
+     * Create a pointerUp event for the current default pointer source
+     *
+     * @param {String} button - Button to release
+     * @param {String?} sourceName - Named pointer source to use or null for the default pointer
+     *                               source
+     * @returns {Actions}
+     */
+    pointerUp: function({button=0, sourceName=null}={}) {
+      let source = this.getSource("pointer", sourceName);
+      source.pointerUp(this, button);
+      return this;
+    },
+
+    /**
+     * Create a move event for the current default pointer source
+     *
+     * @param {Number} x - Destination x coordinate
+     * @param {Number} y - Destination y coordinate
+     * @param {String|Element} origin - Origin of the coordinate system.
+     *                                  Either "pointer", "viewport" or an Element
+     * @param {Number?} duration - Time in ms for the move
+     * @param {String?} sourceName - Named pointer source to use or null for the default pointer
+     *                               source
+     * @returns {Actions}
+     */
+    pointerMove: function(x, y,
+                          {origin="viewport", duration, sourceName=null}={}) {
+      let source = this.getSource("pointer", sourceName);
+      source.pointerMove(this, x, y, duration, origin);
+      return this;
+    },
+  };
+
+  function GeneralSource() {
+    this.actions = new Map();
+  }
+
+  GeneralSource.prototype = {
+    serialize: function(tickCount) {
+      if (!this.actions.size) {
+        return undefined;
+      }
+      let actions = [];
+      let data = {"type": "none", "actions": actions};
+      for (let i=0; i<tickCount; i++) {
+        if (this.actions.has(i)) {
+          actions.push(this.actions.get(i));
+        } else {
+          actions.push({"type": "pause"});
+        }
+      }
+      return data;
+    },
+
+    addPause: function(actions, duration) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        throw new Error(`Already have a pause action for the current tick`);
+      }
+      this.actions.set(tick, {type: "pause", duration: duration});
+    },
+  };
+
+  function KeySource() {
+    this.actions = new Map();
+  }
+
+  KeySource.prototype = {
+    serialize: function(tickCount) {
+      if (!this.actions.size) {
+        return undefined;
+      }
+      let actions = [];
+      let data = {"type": "key", "actions": actions};
+      for (let i=0; i<tickCount; i++) {
+        if (this.actions.has(i)) {
+          actions.push(this.actions.get(i));
+        } else {
+          actions.push({"type": "pause"});
+        }
+      }
+      return data;
+    },
+
+    keyDown: function(actions, key) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        tick = actions.addTick().tickIdx;
+      }
+      this.actions.set(tick, {type: "keyDown", value: key});
+    },
+
+    keyUp: function(actions, key) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        tick = actions.addTick().tickIdx;
+      }
+      this.actions.set(tick, {type: "keyUp", value: key});
+    },
+  };
+
+  function PointerSource(parameters={pointerType: "mouse"}) {
+    let pointerType = parameters.pointerType || "mouse";
+    if (!["mouse", "pen", "touch"].includes(pointerType)) {
+      throw new Error(`Invalid pointerType ${pointerType}`);
+    }
+    this.type = pointerType;
+    this.actions = new Map();
+  }
+
+  PointerSource.prototype = {
+    serialize: function(tickCount) {
+      if (!this.actions.size) {
+        return undefined;
+      }
+      let actions = [];
+      let data = {"type": "pointer", "actions": actions, "parameters": {"pointerType": this.type}};
+      for (let i=0; i<tickCount; i++) {
+        if (this.actions.has(i)) {
+          actions.push(this.actions.get(i));
+        } else {
+          actions.push({"type": "pause"});
+        }
+      }
+      return data;
+    },
+
+    pointerDown: function(actions, button) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        tick = actions.addTick().tickIdx;
+      }
+      this.actions.set(tick, {type: "pointerDown", button});
+    },
+
+    pointerUp: function(actions, button) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        tick = actions.addTick().tickIdx;
+      }
+      this.actions.set(tick, {type: "pointerUp", button});
+    },
+
+    pointerMove: function(actions, x, y, duration, origin) {
+      let tick = actions.tickIdx;
+      if (this.actions.has(tick)) {
+        tick = actions.addTick().tickIdx;
+      }
+      this.actions.set(tick, {type: "pointerMove", x, y, origin});
+      if (duration) {
+        this.actions.get(tick).duration = duration;
+      }
+    },
+  };
+
+  test_driver.Actions = Actions;
+})();
--- a/testing/web-platform/tests/resources/testdriver.js
+++ b/testing/web-platform/tests/resources/testdriver.js
@@ -166,16 +166,39 @@
          * https://github.com/WICG/page-lifecycle/blob/master/README.md|Lifecycle API
          * for Web Pages}
          *
          * @returns {Promise} fulfilled after the freeze request is sent, or rejected
          *                    in case the WebDriver command errors
          */
         freeze: function() {
             return window.test_driver_internal.freeze();
+        },
+
+        /**
+         * Send a sequence of actions
+         *
+         * This function sends a sequence of actions to the top level window
+         * to perform. It is modeled after the behaviour of {@link
+         * https://w3c.github.io/webdriver/#actions|WebDriver Actions Command}
+         *
+         * @param {Array} actions - an array of actions. The format is the same as the actions
+                                    property of the WebDriver command {@link
+                                    https://w3c.github.io/webdriver/#perform-actions|Perform
+                                    Actions} command. Each element is an object representing an
+                                    input source and each input source itself has an actions
+                                    property detailing the behaviour of that source at each timestep
+                                    (or tick). Authors are not expected to construct the actions
+                                    sequence by hand, but to use the builder api provided in
+                                    testdriver-actions.js
+         * @returns {Promise} fufiled after the actions are performed, or rejected in
+         *                    the cases the WebDriver command errors
+         */
+        action_sequence(actions) {
+            return window.test_driver_internal.action_sequence(actions);
         }
     };
 
     window.test_driver_internal = {
         /**
          * Triggers a user-initiated click
          *
          * @param {Element} element - element to be clicked
@@ -200,11 +223,21 @@
         /**
          * Freeze the current page
          *
          * @returns {Promise} fulfilled after freeze request is sent, otherwise
          * it gets rejected
          */
         freeze: function() {
             return Promise.reject(new Error("unimplemented"));
+        },
+
+        /**
+         * Send a sequence of pointer actions
+         *
+         * @returns {Promise} fufilled after actions are sent, rejected if any actions
+         *                    fail
+         */
+        action_sequence: function(actions) {
+            return Promise.reject(new Error("unimplemented"));
         }
     };
 })();
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py
@@ -503,17 +503,18 @@ class CallbackHandler(object):
         self.logger = logger
         self.callbacks = {
             "action": self.process_action,
             "complete": self.process_complete
         }
 
         self.actions = {
             "click": ClickAction(self.logger, self.protocol),
-            "send_keys": SendKeysAction(self.logger, self.protocol)
+            "send_keys": SendKeysAction(self.logger, self.protocol),
+            "action_sequence": ActionSequenceAction(self.logger, self.protocol)
         }
 
     def __call__(self, result):
         url, command, payload = result
         self.logger.debug("Got async callback: %s" % result[1])
         try:
             callback = self.callbacks[command]
         except KeyError:
@@ -534,17 +535,18 @@ class CallbackHandler(object):
                 action_handler = self.actions[action]
             except KeyError:
                 raise ValueError("Unknown action %s" % action)
             try:
                 action_handler(payload)
             except Exception:
                 self.logger.warning("Action %s failed" % action)
                 self.logger.warning(traceback.format_exc())
-                self._send_message("complete", "failure")
+                self._send_message("complete", "error")
+                raise
             else:
                 self.logger.debug("Action %s completed" % action)
                 self._send_message("complete", "success")
         finally:
             self.protocol.base.set_window(parent)
 
         return False, None
 
@@ -554,32 +556,45 @@ class CallbackHandler(object):
 
 class ClickAction(object):
     def __init__(self, logger, protocol):
         self.logger = logger
         self.protocol = protocol
 
     def __call__(self, payload):
         selector = payload["selector"]
-        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")
+        element = self.protocol.select.element_by_selector(selector)
         self.logger.debug("Clicking element: %s" % selector)
-        self.protocol.click.element(elements[0])
+        self.protocol.click.element(element)
 
 
 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")
+        element = self.protocol.select.element_by_selector(selector)
         self.logger.debug("Sending keys to element: %s" % selector)
-        self.protocol.send_keys.send_keys(elements[0], keys)
+        self.protocol.send_keys.send_keys(element, keys)
+
+
+class ActionSequenceAction(object):
+    def __init__(self, logger, protocol):
+        self.logger = logger
+        self.protocol = protocol
+
+    def __call__(self, payload):
+        # TODO: some sort of shallow error checking
+        actions = payload["actions"]
+        for actionSequence in actions:
+            if actionSequence["type"] == "pointer":
+                for action in actionSequence["actions"]:
+                    if (action["type"] == "pointerMove" and
+                        isinstance(action["origin"], dict)):
+                        action["origin"] = self.get_element(action["origin"]["selector"])
+        self.protocol.action_sequence.send_actions({"actions": actions})
+
+    def get_element(self, selector):
+        element = self.protocol.select.element_by_selector(selector)
+        return element
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py
@@ -14,17 +14,18 @@ here = os.path.join(os.path.split(__file
 from .base import (CallbackHandler,
                    RefTestExecutor,
                    RefTestImplementation,
                    TestharnessExecutor,
                    WdspecExecutor,
                    WebDriverProtocol,
                    extra_timeout,
                    strip_server)
-from .protocol import (AssertsProtocolPart,
+from .protocol import (ActionSequenceProtocolPart,
+                       AssertsProtocolPart,
                        BaseProtocolPart,
                        TestharnessProtocolPart,
                        PrefsProtocolPart,
                        Protocol,
                        StorageProtocolPart,
                        SelectorProtocolPart,
                        ClickProtocolPart,
                        SendKeysProtocolPart,
@@ -354,16 +355,26 @@ class MarionetteClickProtocolPart(ClickP
 class MarionetteSendKeysProtocolPart(SendKeysProtocolPart):
     def setup(self):
         self.marionette = self.parent.marionette
 
     def send_keys(self, element, keys):
         return element.send_keys(keys)
 
 
+class MarionetteActionSequenceProtocolPart(ActionSequenceProtocolPart):
+    def setup(self):
+        self.marionette = self.parent.marionette
+
+    def send_actions(self, actions):
+        actions = self.marionette._to_json(actions)
+        self.logger.info(actions)
+        self.marionette._send_message("WebDriver:PerformActions", actions)
+
+
 class MarionetteTestDriverProtocolPart(TestDriverProtocolPart):
     def setup(self):
         self.marionette = self.parent.marionette
 
     def send_message(self, message_type, status, message=None):
         obj = {
             "type": "testdriver-%s" % str(message_type),
             "status": str(status)
@@ -428,16 +439,17 @@ class MarionetteCoverageProtocolPart(Cov
 class MarionetteProtocol(Protocol):
     implements = [MarionetteBaseProtocolPart,
                   MarionetteTestharnessProtocolPart,
                   MarionettePrefsProtocolPart,
                   MarionetteStorageProtocolPart,
                   MarionetteSelectorProtocolPart,
                   MarionetteClickProtocolPart,
                   MarionetteSendKeysProtocolPart,
+                  MarionetteActionSequenceProtocolPart,
                   MarionetteTestDriverProtocolPart,
                   MarionetteAssertsProtocolPart,
                   MarionetteCoverageProtocolPart]
 
     def __init__(self, executor, browser, capabilities=None, timeout_multiplier=1, e10s=True, ccov=False):
         do_delayed_imports()
 
         super(MarionetteProtocol, self).__init__(executor, browser)
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py
@@ -13,33 +13,37 @@ from .base import (CallbackHandler,
                    extra_timeout,
                    strip_server)
 from .protocol import (BaseProtocolPart,
                        TestharnessProtocolPart,
                        Protocol,
                        SelectorProtocolPart,
                        ClickProtocolPart,
                        SendKeysProtocolPart,
+                       ActionSequenceProtocolPart,
                        TestDriverProtocolPart)
 from ..testrunner import Stop
 
 here = os.path.join(os.path.split(__file__)[0])
 
 webdriver = None
 exceptions = None
 RemoteConnection = None
+Command = None
 
 
 def do_delayed_imports():
     global webdriver
     global exceptions
     global RemoteConnection
+    global Command
     from selenium import webdriver
     from selenium.common import exceptions
     from selenium.webdriver.remote.remote_connection import RemoteConnection
+    from selenium.webdriver.remote.command import Command
 
 
 class SeleniumBaseProtocolPart(BaseProtocolPart):
     def setup(self):
         self.webdriver = self.parent.webdriver
 
     def execute_script(self, script, async=False):
         method = self.webdriver.execute_async_script if async else self.webdriver.execute_script
@@ -144,24 +148,33 @@ class SeleniumSelectorProtocolPart(Selec
 
 class SeleniumClickProtocolPart(ClickProtocolPart):
     def setup(self):
         self.webdriver = self.parent.webdriver
 
     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 SeleniumActionSequenceProtocolPart(ActionSequenceProtocolPart):
+    def setup(self):
+        self.webdriver = self.parent.webdriver
+
+    def send_actions(self, actions):
+        self.webdriver.execute(Command.W3C_ACTIONS, {"actions": actions})
+
+
 class SeleniumTestDriverProtocolPart(TestDriverProtocolPart):
     def setup(self):
         self.webdriver = self.parent.webdriver
 
     def send_message(self, message_type, status, message=None):
         obj = {
             "type": "testdriver-%s" % str(message_type),
             "status": str(status)
@@ -172,17 +185,18 @@ class SeleniumTestDriverProtocolPart(Tes
 
 
 class SeleniumProtocol(Protocol):
     implements = [SeleniumBaseProtocolPart,
                   SeleniumTestharnessProtocolPart,
                   SeleniumSelectorProtocolPart,
                   SeleniumClickProtocolPart,
                   SeleniumSendKeysProtocolPart,
-                  SeleniumTestDriverProtocolPart]
+                  SeleniumTestDriverProtocolPart,
+                  SeleniumActionSequenceProtocolPart]
 
     def __init__(self, executor, browser, capabilities, **kwargs):
         do_delayed_imports()
 
         super(SeleniumProtocol, self).__init__(executor, browser)
         self.capabilities = capabilities
         self.url = browser.webdriver_url
         self.webdriver = None
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py
@@ -13,23 +13,25 @@ from .base import (CallbackHandler,
                    extra_timeout,
                    strip_server)
 from .protocol import (BaseProtocolPart,
                        TestharnessProtocolPart,
                        Protocol,
                        SelectorProtocolPart,
                        ClickProtocolPart,
                        SendKeysProtocolPart,
+                       ActionSequenceProtocolPart,
                        TestDriverProtocolPart)
 from ..testrunner import Stop
 
 import webdriver as client
 
 here = os.path.join(os.path.split(__file__)[0])
 
+
 class WebDriverBaseProtocolPart(BaseProtocolPart):
     def setup(self):
         self.webdriver = self.parent.webdriver
 
     def execute_script(self, script, async=False):
         method = self.webdriver.execute_async_script if async else self.webdriver.execute_script
         return method(script)
 
@@ -141,16 +143,24 @@ class WebDriverSendKeysProtocolPart(Send
         except client.UnknownErrorException as e:
             # workaround https://bugs.chromium.org/p/chromedriver/issues/detail?id=1999
             if (e.http_status != 500 or
                 e.status_code != "unknown error"):
                 raise
             return element.send_element_command("POST", "value", {"value": list(keys)})
 
 
+class WebDriverActionSequenceProtocolPart(ActionSequenceProtocolPart):
+    def setup(self):
+        self.webdriver = self.parent.webdriver
+
+    def send_actions(self, actions):
+        self.webdriver.actions.perform(actions)
+
+
 class WebDriverTestDriverProtocolPart(TestDriverProtocolPart):
     def setup(self):
         self.webdriver = self.parent.webdriver
 
     def send_message(self, message_type, status, message=None):
         obj = {
             "type": "testdriver-%s" % str(message_type),
             "status": str(status)
@@ -161,16 +171,17 @@ class WebDriverTestDriverProtocolPart(Te
 
 
 class WebDriverProtocol(Protocol):
     implements = [WebDriverBaseProtocolPart,
                   WebDriverTestharnessProtocolPart,
                   WebDriverSelectorProtocolPart,
                   WebDriverClickProtocolPart,
                   WebDriverSendKeysProtocolPart,
+                  WebDriverActionSequenceProtocolPart,
                   WebDriverTestDriverProtocolPart]
 
     def __init__(self, executor, browser, capabilities, **kwargs):
         super(WebDriverProtocol, self).__init__(executor, browser)
         self.capabilities = capabilities
         self.url = browser.webdriver_url
         self.webdriver = None
 
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py
@@ -237,16 +237,24 @@ class StorageProtocolPart(ProtocolPart):
 
 
 class SelectorProtocolPart(ProtocolPart):
     """Protocol part for selecting elements on the page."""
     __metaclass__ = ABCMeta
 
     name = "select"
 
+    def element_by_selector(self, selector):
+        elements = self.elements_by_selector(selector)
+        if len(elements) == 0:
+            raise ValueError("Selector '%s' matches no elements" % selector)
+        elif len(elements) > 1:
+            raise ValueError("Selector '%s' matches multiple elements" % selector)
+        return elements[0]
+
     @abstractmethod
     def elements_by_selector(self, selector):
         """Select elements matching a CSS selector
 
         :param str selector: The CSS selector
         :returns: A list of protocol-specific handles to elements"""
         pass
 
@@ -274,16 +282,30 @@ class SendKeysProtocolPart(ProtocolPart)
     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 ActionSequenceProtocolPart(ProtocolPart):
+    """Protocol part for performing trusted clicks"""
+    __metaclass__ = ABCMeta
+
+    name = "action_sequence"
+
+    @abstractmethod
+    def send_actions(self, actions):
+        """Send a sequence of actions to the window.
+
+        :param actions: A protocol-specific handle to an array of actions."""
+        pass
+
+
 class TestDriverProtocolPart(ProtocolPart):
     """Protocol part that implements the basic functionality required for
     all testdriver-based tests."""
     __metaclass__ = ABCMeta
 
     name = "testdriver"
 
     @abstractmethod
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js
@@ -65,9 +65,27 @@
         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;
     };
+
+    window.test_driver_internal.action_sequence = function(actions) {
+        const pending_promise = new Promise(function(resolve, reject) {
+            pending_resolve = resolve;
+            pending_reject = reject;
+        });
+        for (let actionSequence of actions) {
+            if (actionSequence.type == "pointer") {
+                for (let action of actionSequence.actions) {
+                    if (action.type == "pointerMove" && action.origin instanceof Element) {
+                        action.origin = {selector: get_selector(action.origin)};
+                    }
+                }
+            }
+        }
+        window.opener.postMessage({"type": "action", "action": "action_sequence", "actions": actions}, "*");
+        return pending_promise;
+    };
 })();