Bug 1333014 - Support intercepted clicks and align with spec; r?whimboo,automatedtester draft
authorAndreas Tolfsen <ato@mozilla.com>
Fri, 03 Feb 2017 19:52:34 +0000
changeset 503049 bb2e786b8ae434d23d07888c6ee56dca7dec9a89
parent 503048 10c5ca8a49e796597bf2aed71b41b20288663678
child 503050 843e27e29cd3e6faa68914a72c1ab802864dd162
push id50471
push userbmo:ato@mozilla.com
push dateWed, 22 Mar 2017 19:18:41 +0000
reviewerswhimboo, automatedtester
bugs1333014
milestone55.0a1
Bug 1333014 - Support intercepted clicks and align with spec; r?whimboo,automatedtester The WebDriver specification changed recently to introduce a new 'element click intercepted' error that is returned if the high-level Element Click command attempts an element that is obscured by another (the other element's z-index, or order in the paint tree, is higher). This patch introduces the notion of 'container elements', which is an element's context. For example, an <option> element's container element or context is the nearest ancestral <select> or <datalist> element. It also makes a distinction between an element being pointer-interactable and merely being in-view. This is important since an element may be in view but not pointer-interactable (i.e. clicking its centre coordinates might be intercepted), and we do not want to wait for an element to become pointer-interactable after scrolling it into view if it is indeed obscured. MozReview-Commit-ID: 8dqGZP6UyOo
testing/marionette/element.js
testing/marionette/harness/marionette_harness/tests/unit/test_click.py
testing/marionette/interaction.js
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -823,23 +823,79 @@ element.inViewport = function (el, x = u
 
   return (vp.left <= c.x + win.pageXOffset &&
       c.x + win.pageXOffset <= vp.right &&
       vp.top <= c.y + win.pageYOffset &&
       c.y + win.pageYOffset <= vp.bottom);
 };
 
 /**
+ * Gets the element's container element.
+ *
+ * An element container is defined by the WebDriver
+ * specification to be an <option> element in a valid element context
+ * (https://html.spec.whatwg.org/#concept-element-contexts), meaning
+ * that it has an ancestral element that is either <datalist> or <select>.
+ *
+ * If the element does not have a valid context, its container element
+ * is itself.
+ *
+ * @param {Element} el
+ *     Element to get the container of.
+ *
+ * @return {Element}
+ *     Container element of |el|.
+ */
+element.getContainer = function (el) {
+  if (el.localName != "option") {
+    return el;
+  }
+
+  function validContext(ctx) {
+    return ctx.localName == "datalist" || ctx.localName == "select";
+  }
+
+  // does <option> have a valid context,
+  // meaning is it a child of <datalist> or <select>?
+  let parent = el;
+  while (parent.parentNode && !validContext(parent)) {
+    parent = parent.parentNode;
+  }
+
+  if (!validContext(parent)) {
+    return el;
+  }
+  return parent;
+};
+
+/**
+ * An element is in view if it is a member of its own pointer-interactable
+ * paint tree.
+ *
+ * This means an element is considered to be in view, but not necessarily
+ * pointer-interactable, if it is found somewhere in the
+ * |elementsFromPoint| list at |el|'s in-view centre coordinates.
+ *
+ * @param {Element} el
+ *     Element to check if is in view.
+ *
+ * @return {boolean}
+ *     True if |el| is inside the viewport, or false otherwise.
+ */
+element.isInView = function (el) {
+  let tree = element.getPointerInteractablePaintTree(el);
+  return tree.includes(el);
+};
+
+/**
  * This function throws the visibility of the element error if the element is
  * not displayed or the given coordinates are not within the viewport.
  *
- * @param {Element} element
+ * @param {Element} el
  *     Element to check if visible.
- * @param {Window} window
- *     Window object.
  * @param {number=} x
  *     Horizontal offset relative to target.  Defaults to the centre of
  *     the target's bounding box.
  * @param {number=} y
  *     Vertical offset relative to target.  Defaults to the centre of
  *     the target's bounding box.
  *
  * @return {boolean}
@@ -879,17 +935,17 @@ element.isInteractable = function (el) {
  *
  * @param {DOMElement} el
  *     Element determine if is pointer-interactable.
  *
  * @return {boolean}
  *     True if interactable, false otherwise.
  */
 element.isPointerInteractable = function (el) {
-  let tree = element.getInteractableElementTree(el, el.ownerDocument);
+  let tree = element.getPointerInteractablePaintTree(el);
   return tree[0] === el;
 };
 
 /**
  * Calculate the in-view centre point of the area of the given DOM client
  * rectangle that is inside the viewport.
  *
  * @param {DOMRect} rect
@@ -923,44 +979,40 @@ element.getInViewCentrePoint = function 
  * Produces a pointer-interactable elements tree from a given element.
  *
  * The tree is defined by the paint order found at the centre point of
  * the element's rectangle that is inside the viewport, excluding the size
  * of any rendered scrollbars.
  *
  * @param {DOMElement} el
  *     Element to determine if is pointer-interactable.
- * @param {DOMDocument} doc
- *     Current browsing context's active document.
  *
  * @return {Array.<DOMElement>}
- *     Sequence of non-opaque elements in paint order.
+ *     Sequence of elements in paint order.
  */
-element.getInteractableElementTree = function (el, doc) {
-  let win = doc.defaultView;
+element.getPointerInteractablePaintTree = function (el) {
+  const doc = el.ownerDocument;
+  const win = doc.defaultView;
 
   // pointer-interactable elements tree, step 1
   if (element.isDisconnected(el, win)) {
     return [];
   }
 
   // steps 2-3
   let rects = el.getClientRects();
   if (rects.length == 0) {
     return [];
   }
 
   // step 4
   let centre = element.getInViewCentrePoint(rects[0], win);
 
   // step 5
-  let tree = doc.elementsFromPoint(centre.x, centre.y);
-
-  // only visible elements are considered interactable
-  return tree.filter(el => win.getComputedStyle(el).opacity === "1");
+  return doc.elementsFromPoint(centre.x, centre.y);
 };
 
 // TODO(ato): Not implemented.
 // In fact, it's not defined in the spec.
 element.isKeyboardInteractable = function (el) {
   return true;
 };
 
--- a/testing/marionette/harness/marionette_harness/tests/unit/test_click.py
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py
@@ -43,45 +43,75 @@ a {
 window.clicked = false;
 
 let link = document.querySelector("a");
 link.addEventListener("click", () => window.clicked = true);
 </script>
 """)
 
 
+obscured_overlay = inline("""
+<style>
+* { margin: 0; padding: 0; }
+body { height: 100vh }
+#overlay {
+  background-color: pink;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+</style>
+
+<div id=overlay></div>
+<a id=obscured href=#>link</a>
+
+<script>
+window.clicked = false;
+
+let link = document.querySelector("#obscured");
+link.addEventListener("click", () => window.clicked = true);
+</script>
+""")
+
+
 class TestLegacyClick(MarionetteTestCase):
     """Uses legacy Selenium element displayedness checks."""
 
     def setUp(self):
         MarionetteTestCase.setUp(self)
         self.marionette.delete_session()
         self.marionette.start_session()
 
     def test_click(self):
-        test_html = self.marionette.absolute_url("test.html")
-        self.marionette.navigate(test_html)
-        link = self.marionette.find_element(By.ID, "mozLink")
-        link.click()
-        self.assertEqual("Clicked", self.marionette.execute_script("return document.getElementById('mozLink').innerHTML;"))
+        self.marionette.navigate(inline("""
+            <button>click me</button>
+            <script>
+              window.clicks = 0;
+              let button = document.querySelector("button");
+              button.addEventListener("click", () => window.clicks++);
+            </script>
+        """))
+        button = self.marionette.find_element(By.TAG_NAME, "button")
+        button.click()
+        self.assertEqual(1, self.marionette.execute_script("return window.clicks", sandbox=None))
 
-    def test_clicking_a_link_made_up_of_numbers_is_handled_correctly(self):
+    def test_click_number_link(self):
         test_html = self.marionette.absolute_url("clicks.html")
         self.marionette.navigate(test_html)
         self.marionette.find_element(By.LINK_TEXT, "333333").click()
         Wait(self.marionette, timeout=30, ignored_exceptions=errors.NoSuchElementException).until(
-            lambda m: m.find_element(By.ID, 'username'))
+            lambda m: m.find_element(By.ID, "username"))
         self.assertEqual(self.marionette.title, "XHTML Test Page")
 
     def test_clicking_an_element_that_is_not_displayed_raises(self):
-        test_html = self.marionette.absolute_url('hidden.html')
+        test_html = self.marionette.absolute_url("hidden.html")
         self.marionette.navigate(test_html)
 
         with self.assertRaises(errors.ElementNotInteractableException):
-            self.marionette.find_element(By.ID, 'child').click()
+            self.marionette.find_element(By.ID, "child").click()
 
     def test_clicking_on_a_multiline_link(self):
         test_html = self.marionette.absolute_url("clicks.html")
         self.marionette.navigate(test_html)
         self.marionette.find_element(By.ID, "overflowLink").click()
         self.wait_for_condition(lambda mn: self.marionette.title == "XHTML Test Page")
 
     def test_scroll_into_view_near_end(self):
@@ -98,27 +128,17 @@ class TestClick(TestLegacyClick):
 
     def setUp(self):
         TestLegacyClick.setUp(self)
         self.marionette.delete_session()
         self.marionette.start_session(
             {"requiredCapabilities": {"specificationLevel": 1}})
 
     def test_click_element_obscured_by_absolute_positioned_element(self):
-        self.marionette.navigate(inline("""
-            <style>
-            * { margin: 0; padding: 0; }
-            div { display: block; width: 100%; height: 100%; }
-            #obscured { background-color: blue }
-            #overlay { background-color: red; position: absolute; top: 0; }
-            </style>
-
-            <div id=obscured></div>
-            <div id=overlay></div>"""))
-
+        self.marionette.navigate(obscured_overlay)
         overlay = self.marionette.find_element(By.ID, "overlay")
         obscured = self.marionette.find_element(By.ID, "obscured")
 
         overlay.click()
         with self.assertRaises(errors.ElementClickInterceptedException):
             obscured.click()
 
     def test_centre_outside_viewport_vertically(self):
@@ -194,8 +214,41 @@ class TestClick(TestLegacyClick):
 
              transform: translateX(-105px);
             }
             </style>
 
             <div></div>"""))
 
         self.marionette.find_element(By.TAG_NAME, "div").click()
+
+    def test_input_file(self):
+        self.marionette.navigate(inline("<input type=file>"))
+        with self.assertRaises(errors.InvalidArgumentException):
+            self.marionette.find_element(By.TAG_NAME, "input").click()
+
+    def test_container_element(self):
+        self.marionette.navigate(inline("""
+            <select>
+              <option>foo</option>
+            </select>"""))
+        option = self.marionette.find_element(By.TAG_NAME, "option")
+        option.click()
+        self.assertTrue(option.get_property("selected"))
+
+    def test_container_element_outside_view(self):
+        self.marionette.navigate(inline("""
+            <select style="margin-top: 100vh">
+              <option>foo</option>
+            </select>"""))
+        option = self.marionette.find_element(By.TAG_NAME, "option")
+        option.click()
+        self.assertTrue(option.get_property("selected"))
+
+    def test_obscured_element(self):
+        self.marionette.navigate(obscured_overlay)
+        overlay = self.marionette.find_element(By.ID, "overlay")
+        obscured = self.marionette.find_element(By.ID, "obscured")
+
+        overlay.click()
+        with self.assertRaises(errors.ElementClickInterceptedException):
+            obscured.click()
+        self.assertFalse(self.marionette.execute_script("return window.clicked", sandbox=None))
--- a/testing/marionette/interaction.js
+++ b/testing/marionette/interaction.js
@@ -80,70 +80,141 @@ this.interaction = {};
 
 /**
  * Interact with an element by clicking it.
  *
  * The element is scrolled into view before visibility- or interactability
  * checks are performed.
  *
  * Selenium-style visibility checks will be performed if |specCompat|
- * is false (default).  Otherwise pointer-interactability
- * checks will be performed.  If either of these fail an
- * {@code ElementNotInteractableError} is returned.
+ * is false (default).  Otherwise pointer-interactability checks will be
+ * performed.  If either of these fail an
+ * {@code ElementNotInteractableError} is thrown.
  *
  * If |strict| is enabled (defaults to disabled), further accessibility
- * checks will be performed, and these may result in an {@code
- * ElementNotAccessibleError} being returned.
+ * checks will be performed, and these may result in an
+ * {@code ElementNotAccessibleError} being returned.
  *
  * When |el| is not enabled, an {@code InvalidElementStateError}
  * is returned.
  *
  * @param {DOMElement|XULElement} el
  *     Element to click.
  * @param {boolean=} strict
  *     Enforce strict accessibility tests.
  * @param {boolean=} specCompat
  *     Use WebDriver specification compatible interactability definition.
  *
- * @throws {ElementNotInteractable}
+ * @throws {ElementNotInteractableError}
  *     If either Selenium-style visibility check or
  *     pointer-interactability check fails.
+ * @throws {ElementClickInterceptedError}
+ *     If |el| is obscured by another element and a click would not hit,
+ *     in |specCompat| mode.
  * @throws {ElementNotAccessibleError}
  *     If |strict| is true and element is not accessible.
  * @throws {InvalidElementStateError}
  *     If |el| is not enabled.
  */
-interaction.clickElement = function*(el, strict = false, specCompat = false) {
+interaction.clickElement = function* (el, strict = false, specCompat = false) {
+  const a11y = accessibility.get(strict);
+  if (specCompat) {
+    yield webdriverClickElement(el, a11y);
+  } else {
+    yield seleniumClickElement(el, a11y);
+  }
+};
+
+function* webdriverClickElement (el, a11y) {
+  const win = getWindow(el);
+  const doc = win.document;
+
+  // step 3
+  if (el.localName == "input" && el.type == "file") {
+    throw new InvalidArgumentError(
+        "Cannot click <input type=file> elements");
+  }
+
+  let containerEl = element.getContainer(el);
+
+  // step 4
+  if (!element.isInView(containerEl)) {
+    element.scrollIntoView(containerEl);
+  }
+
+  // step 5
+  // TODO(ato): wait for containerEl to be in view
+
+  // step 6
+  // if we cannot bring the container element into the viewport
+  // there is no point in checking if it is pointer-interactable
+  if (!element.isInView(containerEl)) {
+    throw new ElementNotInteractableError(
+        error.pprint`Element ${el} could not be scrolled into view`);
+  }
+
+  // step 7
+  let rects = containerEl.getClientRects();
+  let clickPoint = element.getInViewCentrePoint(rects[0], win);
+
+  if (!element.isPointerInteractable(containerEl)) {
+    throw new ElementClickInterceptedError(containerEl, clickPoint);
+  }
+
+  yield a11y.getAccessible(el, true).then(acc => {
+    a11y.assertVisible(acc, el, true);
+    a11y.assertEnabled(acc, el, true);
+    a11y.assertActionable(acc, el);
+  });
+
+  // step 8
+
+  // chrome elements
+  if (element.isXULElement(el)) {
+    if (el.localName == "option") {
+      interaction.selectOption(el);
+    } else {
+      el.click();
+    }
+
+  // content elements
+  } else {
+    if (el.localName == "option") {
+      interaction.selectOption(el);
+    } else {
+      event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win);
+    }
+  }
+
+  // step 9
+  yield interaction.flushEventLoop(win);
+
+  // step 10
+  // TODO(ato): if the click causes navigation,
+  // run post-navigation checks
+}
+
+function* seleniumClickElement (el, a11y) {
   let win = getWindow(el);
-  let a11y = accessibility.get(strict);
 
   let visibilityCheckEl  = el;
   if (el.localName == "option") {
-    visibilityCheckEl = interaction.getSelectForOptionElement(el);
+    visibilityCheckEl = element.getContainer(el);
   }
 
-  let interactable = false;
-  if (specCompat) {
-    if (!element.isPointerInteractable(visibilityCheckEl)) {
-      element.scrollIntoView(el);
-    }
-    interactable = element.isPointerInteractable(visibilityCheckEl);
-  } else {
-    interactable = element.isVisible(visibilityCheckEl);
-  }
-  if (!interactable) {
+  if (!element.isVisible(visibilityCheckEl)) {
     throw new ElementNotInteractableError();
   }
 
   if (!atom.isElementEnabled(el)) {
     throw new InvalidElementStateError("Element is not enabled");
   }
 
   yield a11y.getAccessible(el, true).then(acc => {
-    a11y.assertVisible(acc, el, interactable);
+    a11y.assertVisible(acc, el, true);
     a11y.assertEnabled(acc, el, true);
     a11y.assertActionable(acc, el);
   });
 
   // chrome elements
   if (element.isXULElement(el)) {
     if (el.localName == "option") {
       interaction.selectOption(el);
@@ -151,42 +222,25 @@ interaction.clickElement = function*(el,
       el.click();
     }
 
   // content elements
   } else {
     if (el.localName == "option") {
       interaction.selectOption(el);
     } else {
-      let centre = interaction.calculateCentreCoords(el);
+      let rects = el.getClientRects();
+      let centre = element.getInViewCentrePoint(rects[0], win);
       let opts = {};
       event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
     }
   }
 };
 
 /**
- * Calculate the in-view centre point, that is the centre point of the
- * area of the first DOM client rectangle that is inside the viewport.
- *
- * @param {DOMElement} el
- *     Element to calculate the visible centre point of.
- *
- * @return {Object.<string, number>}
- *     X- and Y-position.
- */
-interaction.calculateCentreCoords = function (el) {
-  let rects = el.getClientRects();
-  return {
-    x: rects[0].left + rects[0].width / 2.0,
-    y: rects[0].top + rects[0].height / 2.0,
-  };
-};
-
-/**
  * Select <option> element in a <select> list.
  *
  * Because the dropdown list of select elements are implemented using
  * native widget technology, our trusted synthesised events are not able
  * to reach them.  Dropdowns are instead handled mimicking DOM events,
  * which for obvious reasons is not ideal, but at the current point in
  * time considered to be good enough.
  *
@@ -202,30 +256,57 @@ interaction.selectOption = function (el)
   if (element.isXULElement(el)) {
     throw new Error("XUL dropdowns not supported");
   }
   if (el.localName != "option") {
     throw new TypeError("Invalid elements");
   }
 
   let win = getWindow(el);
-  let parent = interaction.getSelectForOptionElement(el);
+  let containerEl = element.getContainer(el);
 
-  event.mouseover(parent);
-  event.mousemove(parent);
-  event.mousedown(parent);
-  event.focus(parent);
-  event.input(parent);
+  event.mouseover(containerEl);
+  event.mousemove(containerEl);
+  event.mousedown(containerEl);
+  event.focus(containerEl);
+  event.input(containerEl);
 
   // toggle selectedness the way holding down control works
   el.selected = !el.selected;
 
-  event.change(parent);
-  event.mouseup(parent);
-  event.click(parent);
+  event.change(containerEl);
+  event.mouseup(containerEl);
+  event.click(containerEl);
+};
+
+/**
+ * Flushes the event loop by requesting an animation frame.
+ *
+ * This will wait for the browser to repaint before returning, typically
+ * flushing any queued events.
+ *
+ * If the document is unloaded during this request, the promise is
+ * rejected.
+ *
+ * @param {Window} win
+ *     Associated window.
+ *
+ * @return {Promise}
+ *     Promise is accepted once event queue is flushed, or rejected if
+ *     |win| is unloaded before the queue can be flushed.
+ */
+interaction.flushEventLoop = function* (win) {
+  let unloadEv;
+  return new Promise((resolve, reject) => {
+    unloadEv = reject;
+    win.addEventListener("unload", unloadEv, {once: true});
+    win.requestAnimationFrame(resolve);
+  }).then(() => {
+    win.removeEventListener("unload", unloadEv);
+  });
 };
 
 /**
  * Appends |path| to an <input type=file>'s file list.
  *
  * @param {HTMLInputElement} el
  *     An <input type=file> element.
  * @param {string} path
@@ -256,41 +337,16 @@ interaction.uploadFile = function* (el, 
   event.click(el);
 
   el.mozSetFileArray(fs);
 
   event.change(el);
 };
 
 /**
- * Locate the <select> element that encapsulate an <option> element.
- *
- * @param {HTMLOptionElement} optionEl
- *     Option element.
- *
- * @return {HTMLSelectElement}
- *     Select element wrapping |optionEl|.
- *
- * @throws {Error}
- *     If unable to find the <select> element.
- */
-interaction.getSelectForOptionElement = function (optionEl) {
-  let parent = optionEl;
-  while (parent.parentNode && parent.localName != "select") {
-    parent = parent.parentNode;
-  }
-
-  if (parent.localName != "select") {
-    throw new Error("Unable to find parent of <option> element");
-  }
-
-  return parent;
-};
-
-/**
  * 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} ignoreVisibility
  *     Flag to enable or disable element visibility tests.