Bug 1429338 - Marionette has to honor "moz:useNonSpecCompliantPointerOrigin" capability. r=maja_zf a=test-only
authorHenrik Skupin <mail@hskupin.info>
Tue, 23 Jan 2018 17:31:06 +0100
changeset 454751 b581cd2edcfb512211ebd9e98206690c8452e1f1
parent 454750 1870904d60d370fc85f8a1d189144a6894ffdf9c
child 454752 3c0c748a2fdf80fd60b342fb56852c10d9786ddc
push id1648
push usermtabara@mozilla.com
push dateThu, 01 Mar 2018 12:45:47 +0000
treeherdermozilla-release@cbb9688c2eeb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmaja_zf, test-only
bugs1429338
milestone59.0
Bug 1429338 - Marionette has to honor "moz:useNonSpecCompliantPointerOrigin" capability. r=maja_zf a=test-only This flag is used to turn off the WebDriver spec conforming pointer origin calculation. It has to be kept until all Selenium bindings can successfully handle the WebDriver spec conforming Pointer Origin calculation. MozReview-Commit-ID: 3YknXlWoyi1
testing/marionette/action.js
testing/marionette/driver.js
testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py
testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini
testing/marionette/listener.js
testing/marionette/session.js
testing/marionette/test_session.js
testing/web-platform/meta/MANIFEST.json
testing/web-platform/tests/webdriver/tests/actions/mouse.py
testing/web-platform/tests/webdriver/tests/actions/mouse_dblclick.py
testing/web-platform/tests/webdriver/tests/actions/pointer_origin.py
testing/web-platform/tests/webdriver/tests/actions/support/mouse.py
--- a/testing/marionette/action.js
+++ b/testing/marionette/action.js
@@ -335,22 +335,25 @@ const KEY_CODE_LOOKUP = {
 };
 
 /** Represents possible values for a pointer-move origin. */
 action.PointerOrigin = {
   Viewport: "viewport",
   Pointer: "pointer",
 };
 
+/** Flag for WebDriver spec conforming pointer origin calculation. */
+action.specCompatPointerOrigin = true;
+
 /**
  * Look up a PointerOrigin.
  *
  * @param {(string|Element)=} obj
  *     Origin for a <code>pointerMove</code> action.  Must be one of
- *     "viewport" (default), "pointer", or a DOM element or a DOM element.
+ *     "viewport" (default), "pointer", or a DOM element.
  *
  * @return {action.PointerOrigin}
  *     Pointer origin.
  *
  * @throws {InvalidArgumentError}
  *     If <var>obj</var> is not a valid origin.
  */
 action.PointerOrigin.get = function(obj) {
@@ -966,21 +969,28 @@ action.Mouse = class {
  * tick's actions are not dispatched until the Promise for the current
  * tick is resolved.
  *
  * @param {action.Chain} chain
  *     Actions grouped by tick; each element in |chain| is a sequence of
  *     actions for one tick.
  * @param {WindowProxy} window
  *     Current window global.
+ * @param {boolean=} [specCompatPointerOrigin=true] specCompatPointerOrigin
+ *     Flag to turn off the WebDriver spec conforming pointer origin
+ *     calculation. It has to be kept until all Selenium bindings can
+ *     successfully handle the WebDriver spec conforming Pointer Origin
+ *     calculation. See https://bugzilla.mozilla.org/show_bug.cgi?id=1429338.
  *
  * @return {Promise}
  *     Promise for dispatching all actions in |chain|.
  */
-action.dispatch = function(chain, window) {
+action.dispatch = function(chain, window, specCompatPointerOrigin = true) {
+  action.specCompatPointerOrigin = specCompatPointerOrigin;
+
   let chainEvents = (async () => {
     for (let tickActions of chain) {
       await action.dispatchTickActions(
           tickActions,
           action.computeTickDuration(tickActions),
           window);
     }
   })();
@@ -1003,18 +1013,17 @@ action.dispatch = function(chain, window
  * @param {number} tickDuration
  *     Duration in milliseconds of this tick.
  * @param {WindowProxy} window
  *     Current window global.
  *
  * @return {Promise}
  *     Promise for dispatching all tick-actions and pending DOM events.
  */
-action.dispatchTickActions = function(
-    tickActions, tickDuration, window) {
+action.dispatchTickActions = function(tickActions, tickDuration, window) {
   let pendingEvents = tickActions.map(toEvents(tickDuration, window));
   return Promise.all(pendingEvents);
 };
 
 /**
  * Compute tick duration in milliseconds for a collection of actions.
  *
  * @param {Array.<action.Action>} tickActions
@@ -1424,12 +1433,15 @@ function inViewPort(x, y, win) {
   assert.number(x, `Expected x to be finite number`);
   assert.number(y, `Expected y to be finite number`);
   // Viewport includes scrollbars if rendered.
   return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight);
 }
 
 function getElementCenter(el, window) {
   if (element.isDOMElement(el)) {
-    return element.getInViewCentrePoint(el.getClientRects()[0], window);
+    if (action.specCompatPointerOrigin) {
+      return element.getInViewCentrePoint(el.getClientRects()[0], window);
+    }
+    return element.coordinates(el);
   }
   return {};
 }
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -596,16 +596,20 @@ GeckoDriver.prototype.listeningPromise =
  *  <dd>Describes the timeouts imposed on certian session operations.
  *
  *  <dt><code>proxy</code> (Proxy object)
  *  <dd>Defines the proxy configuration.
  *
  *  <dt><code>moz:accessibilityChecks</code> (boolean)
  *  <dd>Run a11y checks when clicking elements.
  *
+ *  <dt><code>moz:useNonSpecCompliantPointerOrigin</code> (boolean)
+ *  <dd>Use the not WebDriver conforming calculation of the pointer origin
+ *   when the origin is an element, and the element center point is used.
+ *
  *  <dt><code>moz:webdriverClick</code> (boolean)
  *  <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>.
  * </dl>
  *
  * <h4>Timeouts object</h4>
  *
  * <dl>
  *  <dt><code>script</code> (number)
--- a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py
@@ -63,24 +63,34 @@ class TestCapabilities(MarionetteTestCas
                 current_profile = self.marionette.instance.runner.device.app_ctx.remote_profile
             else:
                 current_profile = convert_path(self.marionette.instance.runner.profile.profile)
             self.assertEqual(convert_path(str(self.caps["moz:profile"])), current_profile)
             self.assertEqual(convert_path(str(self.marionette.profile)), current_profile)
 
         self.assertIn("moz:accessibilityChecks", self.caps)
         self.assertFalse(self.caps["moz:accessibilityChecks"])
+
+        self.assertIn("moz:useNonSpecCompliantPointerOrigin", self.caps)
+        self.assertFalse(self.caps["moz:useNonSpecCompliantPointerOrigin"])
+
         self.assertIn("moz:webdriverClick", self.caps)
-        self.assertEqual(self.caps["moz:webdriverClick"], True)
+        self.assertTrue(self.caps["moz:webdriverClick"])
 
     def test_disable_webdriver_click(self):
         self.marionette.delete_session()
         self.marionette.start_session({"moz:webdriverClick": False})
         caps = self.marionette.session_capabilities
-        self.assertEqual(False, caps["moz:webdriverClick"])
+        self.assertFalse(caps["moz:webdriverClick"])
+
+    def test_use_non_spec_compliant_pointer_origin(self):
+        self.marionette.delete_session()
+        self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True})
+        caps = self.marionette.session_capabilities
+        self.assertTrue(caps["moz:useNonSpecCompliantPointerOrigin"])
 
     def test_we_get_valid_uuid4_when_creating_a_session(self):
         self.assertNotIn("{", self.marionette.session_id,
                          "Session ID has {{}} in it: {}".format(
                              self.marionette.session_id))
 
 
 class TestCapabilityMatching(MarionetteTestCase):
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
@@ -0,0 +1,145 @@
+# 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/.
+
+from __future__ import absolute_import
+
+import urllib
+
+from marionette_driver import By, errors, Wait
+from marionette_driver.keys import Keys
+from marionette_driver.marionette import W3C_WEBELEMENT_KEY
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+    return "data:text/html;charset=utf-8,{}".format(urllib.quote(doc))
+
+
+class Actions(object):
+    """Temporary class until Marionette client supports the WebDriver actions."""
+
+    def __init__(self, marionette):
+        self.marionette = marionette
+
+        self.action_chain = []
+
+    def perform(self):
+        params = {"actions": [{
+            "actions": self.action_chain,
+            "id": "mouse",
+            "parameters": {
+                "pointerType": "mouse"
+            },
+            "type": "pointer"
+        }]}
+
+        return self.marionette._send_message("performActions", params=params)
+
+    def move(self, element, x=0, y=0, duration=250):
+        self.action_chain.append({
+            "duration": duration,
+            "origin": {
+                W3C_WEBELEMENT_KEY: element.id
+            },
+            "type": "pointerMove",
+            "x": x,
+            "y": y,
+        })
+
+        return self
+
+    def click(self):
+        self.action_chain.extend([{
+            "button": 0,
+            "type": "pointerDown"
+        }, {
+            "button": 0,
+            "type": "pointerUp"
+        }])
+
+        return self
+
+
+class BaseMouseAction(MarionetteTestCase):
+
+    def setUp(self):
+        super(BaseMouseAction, self).setUp()
+
+        if self.marionette.session_capabilities["platformName"] == "darwin":
+            self.mod_key = Keys.META
+        else:
+            self.mod_key = Keys.CONTROL
+
+        self.action = Actions(self.marionette)
+
+    def tearDown(self):
+        super(BaseMouseAction, self).tearDown()
+
+    @property
+    def click_position(self):
+        return self.marionette.execute_script("""
+          if (window.click_x && window.click_y) {
+            return {x: window.click_x, y: window.click_y};
+          }
+        """, sandbox=None)
+
+    def get_element_center_point(self, elem):
+        return {
+            "x": elem.location["x"] + elem.size["width"] / 2,
+            "y": elem.location["y"] + elem.size["height"] / 2
+        }
+
+
+class TestNonSpecCompliantPointerOrigin(BaseMouseAction):
+
+    def setUp(self):
+        super(TestNonSpecCompliantPointerOrigin, self).setUp()
+
+        self.marionette.delete_session()
+        self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True})
+
+    def tearDown(self):
+        self.marionette.delete_session()
+        self.marionette.start_session()
+
+        super(TestNonSpecCompliantPointerOrigin, self).tearDown()
+
+    def test_click_element_smaller_than_viewport(self):
+        self.marionette.navigate(inline("""
+          <div id="div" style="width: 10vw; height: 10vh; background: green;"
+               onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+        """))
+        elem = self.marionette.find_element(By.ID, "div")
+        elem_center_point = self.get_element_center_point(elem)
+
+        self.action.move(elem).click().perform()
+        click_position = Wait(self.marionette).until(lambda _: self.click_position,
+                                                     message="No click event has been detected")
+        self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1)
+        self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1)
+
+    def test_click_element_larger_than_viewport_with_center_point_inside(self):
+        self.marionette.navigate(inline("""
+          <div id="div" style="width: 150vw; height: 150vh; background: green;"
+               onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+        """))
+        elem = self.marionette.find_element(By.ID, "div")
+        elem_center_point = self.get_element_center_point(elem)
+
+        self.action.move(elem).click().perform()
+        click_position = Wait(self.marionette).until(lambda _: self.click_position,
+                                                     message="No click event has been detected")
+        self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1)
+        self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1)
+
+    def test_click_element_larger_than_viewport_with_center_point_outside(self):
+        self.marionette.navigate(inline("""
+          <div id="div" style="width: 300vw; height: 300vh; background: green;"
+               onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+        """))
+        elem = self.marionette.find_element(By.ID, "div")
+
+        with self.assertRaises(errors.MoveTargetOutOfBoundsException):
+            self.action.move(elem).click().perform()
--- a/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini
+++ b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini
@@ -98,16 +98,18 @@ skip-if = manage_instance == false || ap
 skip-if = manage_instance == false || appname == 'fennec' # Bug 1298921 and bug 1322993
 [test_with_using_context.py]
 
 [test_modal_dialogs.py]
 skip-if = appname == 'fennec' # Bug 1325738
 [test_key_actions.py]
 [test_legacy_mouse_action.py]
 skip-if = appname == 'fennec'
+[test_mouse_action.py]
+skip-if = appname == 'fennec'
 [test_teardown_context_preserved.py]
 [test_file_upload.py]
 skip-if = appname == 'fennec' || os == "win" # http://bugs.python.org/issue14574
 
 [test_execute_sandboxes.py]
 [test_prefs.py]
 
 [test_shadow_dom.py]
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -782,17 +782,19 @@ function createATouch(el, corx, cory, to
  * Perform a series of grouped actions at the specified points in time.
  *
  * @param {obj} msg
  *      Object with an |actions| attribute that is an Array of objects
  *      each of which represents an action sequence.
  */
 async function performActions(msg) {
   let chain = action.Chain.fromJSON(msg.actions);
-  await action.dispatch(chain, curContainer.frame);
+  await action.dispatch(chain, curContainer.frame,
+      !capabilities.get("moz:useNonSpecCompliantPointerOrigin"),
+  );
 }
 
 /**
  * The release actions command is used to release all the keys and pointer
  * buttons that are currently depressed. This causes events to be fired
  * as if the state was released by an explicit series of actions. It also
  * clears all the internal state of the virtual devices.
  */
--- a/testing/marionette/session.js
+++ b/testing/marionette/session.js
@@ -373,16 +373,17 @@ session.Capabilities = class extends Map
       // features
       ["rotatable", appinfo.name == "B2G"],
 
       // proprietary
       ["moz:accessibilityChecks", false],
       ["moz:headless", Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless],
       ["moz:processID", Services.appinfo.processID],
       ["moz:profile", maybeProfile()],
+      ["moz:useNonSpecCompliantPointerOrigin", false],
       ["moz:webdriverClick", true],
     ]);
   }
 
   /**
    * @param {string} key
    *     Capability key.
    * @param {(string|number|boolean)} value
@@ -460,25 +461,30 @@ session.Capabilities = class extends Map
           matched.set("proxy", proxy);
           break;
 
         case "timeouts":
           let timeouts = session.Timeouts.fromJSON(v);
           matched.set("timeouts", timeouts);
           break;
 
+        case "moz:accessibilityChecks":
+          assert.boolean(v);
+          matched.set("moz:accessibilityChecks", v);
+          break;
+
+        case "moz:useNonSpecCompliantPointerOrigin":
+          assert.boolean(v);
+          matched.set("moz:useNonSpecCompliantPointerOrigin", v);
+          break;
+
         case "moz:webdriverClick":
           assert.boolean(v);
           matched.set("moz:webdriverClick", v);
           break;
-
-        case "moz:accessibilityChecks":
-          assert.boolean(v);
-          matched.set("moz:accessibilityChecks", v);
-          break;
       }
     }
 
     return matched;
   }
 };
 
 // Specialisation of |JSON.stringify| that produces JSON-safe object
--- a/testing/marionette/test_session.js
+++ b/testing/marionette/test_session.js
@@ -374,16 +374,17 @@ add_test(function test_Capabilities_ctor
   ok(caps.get("timeouts") instanceof session.Timeouts);
   ok(caps.get("proxy") instanceof session.Proxy);
 
   ok(caps.has("rotatable"));
 
   equal(false, caps.get("moz:accessibilityChecks"));
   ok(caps.has("moz:processID"));
   ok(caps.has("moz:profile"));
+  equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
   equal(true, caps.get("moz:webdriverClick"));
 
   run_next_test();
 });
 
 add_test(function test_Capabilities_toString() {
   equal("[object session.Capabilities]", new session.Capabilities().toString());
 
@@ -403,16 +404,18 @@ add_test(function test_Capabilities_toJS
   deepEqual(caps.get("timeouts").toJSON(), json.timeouts);
   equal(undefined, json.proxy);
 
   equal(caps.get("rotatable"), json.rotatable);
 
   equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]);
   equal(caps.get("moz:processID"), json["moz:processID"]);
   equal(caps.get("moz:profile"), json["moz:profile"]);
+  equal(caps.get("moz:useNonSpecCompliantPointerOrigin"),
+        json["moz:useNonSpecCompliantPointerOrigin"]);
   equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]);
 
   run_next_test();
 });
 
 add_test(function test_Capabilities_fromJSON() {
   const {fromJSON} = session.Capabilities;
 
@@ -442,27 +445,36 @@ add_test(function test_Capabilities_from
   let proxyConfig = {proxyType: "manual"};
   caps = fromJSON({proxy: proxyConfig});
   equal("manual", caps.get("proxy").proxyType);
 
   let timeoutsConfig = {implicit: 123};
   caps = fromJSON({timeouts: timeoutsConfig});
   equal(123, caps.get("timeouts").implicit);
 
-  equal(true, caps.get("moz:webdriverClick"));
-  caps = fromJSON({"moz:webdriverClick": true});
-  equal(true, caps.get("moz:webdriverClick"));
-  Assert.throws(() => fromJSON({"moz:webdriverClick": "foo"}));
-  Assert.throws(() => fromJSON({"moz:webdriverClick": 1}));
-
   caps = fromJSON({"moz:accessibilityChecks": true});
   equal(true, caps.get("moz:accessibilityChecks"));
   caps = fromJSON({"moz:accessibilityChecks": false});
   equal(false, caps.get("moz:accessibilityChecks"));
   Assert.throws(() => fromJSON({"moz:accessibilityChecks": "foo"}));
+  Assert.throws(() => fromJSON({"moz:accessibilityChecks": 1}));
+
+  caps = fromJSON({"moz:useNonSpecCompliantPointerOrigin": false});
+  equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+  caps = fromJSON({"moz:useNonSpecCompliantPointerOrigin": true});
+  equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+  Assert.throws(() => fromJSON({"moz:useNonSpecCompliantPointerOrigin": "foo"}));
+  Assert.throws(() => fromJSON({"moz:useNonSpecCompliantPointerOrigin": 1}));
+
+  caps = fromJSON({"moz:webdriverClick": true});
+  equal(true, caps.get("moz:webdriverClick"));
+  caps = fromJSON({"moz:webdriverClick": false});
+  equal(false, caps.get("moz:webdriverClick"));
+  Assert.throws(() => fromJSON({"moz:webdriverClick": "foo"}));
+  Assert.throws(() => fromJSON({"moz:webdriverClick": 1}));
 
   run_next_test();
 });
 
 // use session.Proxy.toJSON to test marshal
 add_test(function test_marshal() {
   let proxy = new session.Proxy();
 
--- a/testing/web-platform/meta/MANIFEST.json
+++ b/testing/web-platform/meta/MANIFEST.json
@@ -378317,16 +378317,22 @@
     ]
    ],
    "webdriver/tests/actions/mouse_dblclick.py": [
     [
      "/webdriver/tests/actions/mouse_dblclick.py",
      {}
     ]
    ],
+   "webdriver/tests/actions/pointer_origin.py": [
+    [
+     "/webdriver/tests/actions/pointer_origin.py",
+     {}
+    ]
+   ],
    "webdriver/tests/actions/sequence.py": [
     [
      "/webdriver/tests/actions/sequence.py",
      {
       "timeout": "long"
      }
     ]
    ],
@@ -531375,17 +531381,17 @@
    "bef45b2ff4f474982a84d80d3e37ccae0d459f4b",
    "testharness"
   ],
   "domparsing/OWNERS": [
    "da58846d7ccfd175f043332a58c73feab9394e63",
    "support"
   ],
   "domparsing/XMLSerializer-serializeToString.html": [
-   "121db7defe0663346fd83ba832e09d20ce6d41be",
+   "71314752b8552c19b0951647594b9c5c85ca01b6",
    "testharness"
   ],
   "domparsing/createContextualFragment.html": [
    "e3d576cab516b137b3c758f4ab7528303a28744a",
    "testharness"
   ],
   "domparsing/innerhtml-01.xhtml": [
    "a8db52434a64ea628f168583c5bef0557c79254a",
@@ -583343,25 +583349,29 @@
    "d589b53f0096893600e696b43ec19ca84e5ee2ab",
    "wdspec"
   ],
   "webdriver/tests/actions/key_shortcuts.py": [
    "dbe27dd0b1625169fc8cc2055f8fb49d5a4a78d2",
    "wdspec"
   ],
   "webdriver/tests/actions/modifier_click.py": [
-   "88a384182fdd9df1515b9d8cfda8f56aed138ec7",
+   "56df38086ef05cd8bff1437038efb598ab63f1e3",
    "wdspec"
   ],
   "webdriver/tests/actions/mouse.py": [
-   "708373af0d50f2a0a9776743848482c939f90ec8",
+   "2fb4c47335f144a2dd6f16db4c20239116f20fed",
    "wdspec"
   ],
   "webdriver/tests/actions/mouse_dblclick.py": [
-   "154d595a3d4466a44c5217c54bb3c717d9a2b9ec",
+   "932b053eef5e052d53ab2007540428d68b758ad4",
+   "wdspec"
+  ],
+  "webdriver/tests/actions/pointer_origin.py": [
+   "da2a9f21018582c8cd52d206d172841f71fd19f3",
    "wdspec"
   ],
   "webdriver/tests/actions/sequence.py": [
    "d43caf0f8607a76c3baed7806664b686bde21fda",
    "wdspec"
   ],
   "webdriver/tests/actions/special_keys.py": [
    "64eb2401664b71d68f7b53e236a947eec6d651cc",
@@ -583379,17 +583389,17 @@
    "208a1c4fbc0d5c542d17de7f6474d477ce1feb45",
    "support"
   ],
   "webdriver/tests/actions/support/refine.py": [
    "0d244bffe67ef57be68aad99f1cbc7440ff80e27",
    "support"
   ],
   "webdriver/tests/actions/support/test_actions_wdspec.html": [
-   "95203777fcc012ab64465287737a89a4ba2c31dc",
+   "34f99c46ac9c52e5902477c26a3d16a89a29235a",
    "support"
   ],
   "webdriver/tests/conftest.py": [
    "c812269d034c9ca1b8c4f136dd5d0cea52f4d0f0",
    "support"
   ],
   "webdriver/tests/contexts/json_serialize_windowproxy.py": [
    "d29c82c48b3bd1e2b07c40798a774eb77d6178a5",
--- a/testing/web-platform/tests/webdriver/tests/actions/mouse.py
+++ b/testing/web-platform/tests/webdriver/tests/actions/mouse.py
@@ -1,27 +1,22 @@
 import pytest
 
-from tests.actions.support.mouse import get_center
+from tests.actions.support.mouse import get_inview_center, get_viewport_rect
 from tests.actions.support.refine import get_events, filter_dict
 from tests.support.asserts import assert_move_to_coordinates
 from tests.support.inline import inline
 from tests.support.wait import wait
 
 
 def link_doc(dest):
     content = "<a href=\"{}\" id=\"link\">destination</a>".format(dest)
     return inline(content)
 
 
-# TODO use pytest.approx once we upgrade to pytest > 3.0
-def approx(n, m, tolerance=1):
-    return abs(n - m) <= tolerance
-
-
 def test_click_at_coordinates(session, test_actions_page, mouse_chain):
     div_point = {
         "x": 82,
         "y": 187,
     }
     mouse_chain \
         .pointer_move(div_point["x"], div_point["y"], duration=1000) \
         .click() \
@@ -30,17 +25,17 @@ def test_click_at_coordinates(session, t
     assert len(events) == 4
     assert_move_to_coordinates(div_point, "outer", events)
     for e in events:
         if e["type"] != "mousedown":
             assert e["buttons"] == 0
         assert e["button"] == 0
     expected = [
         {"type": "mousedown", "buttons": 1},
-        {"type": "mouseup",  "buttons": 0},
+        {"type": "mouseup", "buttons": 0},
         {"type": "click", "buttons": 0},
     ]
     filtered_events = [filter_dict(e, expected[0]) for e in events]
     assert expected == filtered_events[1:]
 
 
 def test_context_menu_at_coordinates(session, test_actions_page, mouse_chain):
     div_point = {
@@ -50,39 +45,39 @@ def test_context_menu_at_coordinates(ses
     mouse_chain \
         .pointer_move(div_point["x"], div_point["y"]) \
         .pointer_down(button=2) \
         .pointer_up(button=2) \
         .perform()
     events = get_events(session)
     expected = [
         {"type": "mousedown", "button": 2},
-        {"type": "contextmenu",  "button": 2},
+        {"type": "contextmenu", "button": 2},
     ]
     assert len(events) == 4
     filtered_events = [filter_dict(e, expected[0]) for e in events]
     mousedown_contextmenu_events = [
         x for x in filtered_events
         if x["type"] in ["mousedown", "contextmenu"]
     ]
     assert expected == mousedown_contextmenu_events
 
 
 def test_click_element_center(session, test_actions_page, mouse_chain):
     outer = session.find.css("#outer", all=False)
-    center = get_center(outer.rect)
+    center = get_inview_center(outer.rect, get_viewport_rect(session))
     mouse_chain.click(element=outer).perform()
     events = get_events(session)
     assert len(events) == 4
     event_types = [e["type"] for e in events]
     assert ["mousemove", "mousedown", "mouseup", "click"] == event_types
     for e in events:
         if e["type"] != "mousemove":
-            assert approx(e["pageX"], center["x"])
-            assert approx(e["pageY"], center["y"])
+            assert pytest.approx(e["pageX"], center["x"])
+            assert pytest.approx(e["pageY"], center["y"])
             assert e["target"] == "outer"
 
 
 def test_click_navigation(session, url, release_actions):
     destination = url("/webdriver/tests/actions/support/test_actions_wdspec.html")
     start = link_doc(destination)
 
     def click(link):
@@ -97,37 +92,38 @@ def test_click_navigation(session, url, 
     wait(session, lambda s: s.url == destination, error_message)
     # repeat steps to check behaviour after document unload
     session.url = start
     click(session.find.css("#link", all=False))
     wait(session, lambda s: s.url == destination, error_message)
 
 
 @pytest.mark.parametrize("drag_duration", [0, 300, 800])
-@pytest.mark.parametrize("dx, dy",
-    [(20, 0), (0, 15), (10, 15), (-20, 0), (10, -15), (-10, -15)])
+@pytest.mark.parametrize("dx, dy", [
+    (20, 0), (0, 15), (10, 15), (-20, 0), (10, -15), (-10, -15)
+])
 def test_drag_and_drop(session,
                        test_actions_page,
                        mouse_chain,
                        dx,
                        dy,
                        drag_duration):
     drag_target = session.find.css("#dragTarget", all=False)
     initial_rect = drag_target.rect
-    initial_center = get_center(initial_rect)
+    initial_center = get_inview_center(initial_rect, get_viewport_rect(session))
     # Conclude chain with extra move to allow time for last queued
     # coordinate-update of drag_target and to test that drag_target is "dropped".
     mouse_chain \
         .pointer_move(0, 0, origin=drag_target) \
         .pointer_down() \
         .pointer_move(dx, dy, duration=drag_duration, origin="pointer") \
         .pointer_up() \
         .pointer_move(80, 50, duration=100, origin="pointer") \
         .perform()
     # mouseup that ends the drag is at the expected destination
     e = get_events(session)[1]
     assert e["type"] == "mouseup"
-    assert approx(e["pageX"], initial_center["x"] + dx)
-    assert approx(e["pageY"], initial_center["y"] + dy)
+    assert pytest.approx(e["pageX"], initial_center["x"] + dx)
+    assert pytest.approx(e["pageY"], initial_center["y"] + dy)
     # check resulting location of the dragged element
     final_rect = drag_target.rect
     assert initial_rect["x"] + dx == final_rect["x"]
     assert initial_rect["y"] + dy == final_rect["y"]
--- a/testing/web-platform/tests/webdriver/tests/actions/mouse_dblclick.py
+++ b/testing/web-platform/tests/webdriver/tests/actions/mouse_dblclick.py
@@ -1,11 +1,11 @@
 import pytest
 
-from tests.actions.support.mouse import get_center
+from tests.actions.support.mouse import get_inview_center, get_viewport_rect
 from tests.actions.support.refine import get_events, filter_dict
 from tests.support.asserts import assert_move_to_coordinates
 
 
 _DBLCLICK_INTERVAL = 640
 
 
 # Using local fixtures because we want to start a new session between
@@ -58,17 +58,17 @@ def test_dblclick_at_coordinates(dblclic
     ]
     assert len(events) == 8
     filtered_events = [filter_dict(e, expected[0]) for e in events]
     assert expected == filtered_events[1:]
 
 
 def test_dblclick_with_pause_after_second_pointerdown(dblclick_session, mouse_chain):
         outer = dblclick_session.find.css("#outer", all=False)
-        center = get_center(outer.rect)
+        center = get_inview_center(outer.rect, get_viewport_rect(dblclick_session))
         mouse_chain \
             .pointer_move(int(center["x"]), int(center["y"])) \
             .click() \
             .pointer_down() \
             .pause(_DBLCLICK_INTERVAL + 10) \
             .pointer_up() \
             .perform()
         events = get_events(dblclick_session)
@@ -83,17 +83,17 @@ def test_dblclick_with_pause_after_secon
         ]
         assert len(events) == 8
         filtered_events = [filter_dict(e, expected[0]) for e in events]
         assert expected == filtered_events[1:]
 
 
 def test_no_dblclick(dblclick_session, mouse_chain):
         outer = dblclick_session.find.css("#outer", all=False)
-        center = get_center(outer.rect)
+        center = get_inview_center(outer.rect, get_viewport_rect(dblclick_session))
         mouse_chain \
             .pointer_move(int(center["x"]), int(center["y"])) \
             .click() \
             .pause(_DBLCLICK_INTERVAL + 10) \
             .click() \
             .perform()
         events = get_events(dblclick_session)
         expected = [
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/webdriver/tests/actions/pointer_origin.py
@@ -0,0 +1,129 @@
+import pytest
+
+from webdriver import MoveTargetOutOfBoundsException
+
+from tests.actions.support.mouse import get_inview_center, get_viewport_rect
+from tests.support.inline import inline
+
+
+def origin_doc(inner_style, outer_style=""):
+    return inline("""
+      <div id="outer" style="{1}"
+           onmousemove="window.coords = {{x: event.clientX, y: event.clientY}}">
+        <div id="inner" style="{0}"></div>
+      </div>
+    """.format(inner_style, outer_style))
+
+
+def get_click_coordinates(session):
+    return session.execute_script("return window.coords;")
+
+
+def test_viewport_inside(session, mouse_chain):
+    point = {"x": 50, "y": 50}
+
+    session.url = origin_doc("width: 100px; height: 50px; background: green;")
+    mouse_chain \
+        .pointer_move(point["x"], point["y"], origin="viewport") \
+        .perform()
+
+    click_coords = session.execute_script("return window.coords;")
+    assert pytest.approx(click_coords["x"], point["x"])
+    assert pytest.approx(click_coords["y"], point["y"])
+
+
+def test_viewport_outside(session, mouse_chain):
+    with pytest.raises(MoveTargetOutOfBoundsException):
+        mouse_chain \
+            .pointer_move(-50, -50, origin="viewport") \
+            .perform()
+
+
+def test_pointer_inside(session, mouse_chain):
+    start_point = {"x": 50, "y": 50}
+    offset = {"x": 10, "y": 5}
+
+    session.url = origin_doc("width: 100px; height: 50px; background: green;")
+    mouse_chain \
+        .pointer_move(start_point["x"], start_point["y"]) \
+        .pointer_move(offset["x"], offset["y"], origin="pointer") \
+        .perform()
+
+    click_coords = session.execute_script("return window.coords;")
+    assert pytest.approx(click_coords["x"], start_point["x"] + offset["x"])
+    assert pytest.approx(click_coords["y"], start_point["y"] + offset["y"])
+
+
+def test_pointer_outside(session, mouse_chain):
+    with pytest.raises(MoveTargetOutOfBoundsException):
+        mouse_chain \
+            .pointer_move(-50, -50, origin="pointer") \
+            .perform()
+
+
+def test_element_center_point(session, mouse_chain):
+    session.url = origin_doc("width: 100px; height: 50px; background: green;")
+    elem = session.find.css("#inner", all=False)
+    center = get_inview_center(elem.rect, get_viewport_rect(session))
+
+    mouse_chain \
+        .pointer_move(0, 0, origin=elem) \
+        .perform()
+
+    click_coords = get_click_coordinates(session)
+    assert pytest.approx(click_coords["x"], center["x"])
+    assert pytest.approx(click_coords["y"], center["y"])
+
+
+def test_element_center_point_with_offset(session, mouse_chain):
+    session.url = origin_doc("width: 100px; height: 50px; background: green;")
+    elem = session.find.css("#inner", all=False)
+    center = get_inview_center(elem.rect, get_viewport_rect(session))
+
+    mouse_chain \
+        .pointer_move(10, 15, origin=elem) \
+        .perform()
+
+    click_coords = get_click_coordinates(session)
+    assert pytest.approx(click_coords["x"], center["x"] + 10)
+    assert pytest.approx(click_coords["y"], center["y"] + 15)
+
+
+def test_element_in_view_center_point_partly_visible(session, mouse_chain):
+    session.url = origin_doc("""width: 100px; height: 50px; background: green;
+                                position: relative; left: -50px; top: -25px;""")
+    elem = session.find.css("#inner", all=False)
+    center = get_inview_center(elem.rect, get_viewport_rect(session))
+
+    mouse_chain \
+        .pointer_move(0, 0, origin=elem) \
+        .perform()
+
+    click_coords = get_click_coordinates(session)
+    assert pytest.approx(click_coords["x"], center["x"])
+    assert pytest.approx(click_coords["y"], center["y"])
+
+
+def test_element_larger_than_viewport(session, mouse_chain):
+    session.url = origin_doc("width: 300vw; height: 300vh; background: green;")
+    elem = session.find.css("#inner", all=False)
+    center = get_inview_center(elem.rect, get_viewport_rect(session))
+
+    mouse_chain \
+        .pointer_move(0, 0, origin=elem) \
+        .perform()
+
+    click_coords = get_click_coordinates(session)
+    assert pytest.approx(click_coords["x"], center["x"])
+    assert pytest.approx(click_coords["y"], center["y"])
+
+
+def test_element_outside_of_view_port(session, mouse_chain):
+    session.url = origin_doc("""width: 100px; height: 50px; background: green;
+                                position: relative; left: -200px; top: -100px;""")
+    elem = session.find.css("#inner", all=False)
+
+    with pytest.raises(MoveTargetOutOfBoundsException):
+        mouse_chain \
+            .pointer_move(0, 0, origin=elem) \
+            .perform()
--- a/testing/web-platform/tests/webdriver/tests/actions/support/mouse.py
+++ b/testing/web-platform/tests/webdriver/tests/actions/support/mouse.py
@@ -1,5 +1,26 @@
-def get_center(rect):
+def get_viewport_rect(session):
+    return session.execute_script("""
+        return {
+          height: window.innerHeight || document.documentElement.clientHeight,
+          width: window.innerWidth || document.documentElement.clientWidth,
+        };
+    """)
+
+
+def get_inview_center(elem_rect, viewport_rect):
+    x = {
+        "left": max(0, min(elem_rect["x"], elem_rect["x"] + elem_rect["width"])),
+        "right": min(viewport_rect["width"], max(elem_rect["x"],
+                                                 elem_rect["x"] + elem_rect["width"])),
+    }
+
+    y = {
+        "top": max(0, min(elem_rect["y"], elem_rect["y"] + elem_rect["height"])),
+        "bottom": min(viewport_rect["height"], max(elem_rect["y"],
+                                                   elem_rect["y"] + elem_rect["height"])),
+    }
+
     return {
-        "x": rect["width"] / 2 + rect["x"],
-        "y": rect["height"] / 2 + rect["y"],
+        "x": (x["left"] + x["right"]) / 2,
+        "y": (y["top"] + y["bottom"]) / 2,
     }