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
--- 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.