Bug 1333014 - Support intercepted clicks and align with spec; r=automatedtester,whimboo

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

--HG--
extra : rebase_source : 68f1f7ee922ab8ed6acd92d3f89d6887b23ae801
This commit is contained in:
Andreas Tolfsen 2017-02-03 19:52:34 +00:00
Родитель b094d489ab
Коммит 287f6b6d18
3 изменённых файлов: 267 добавлений и 106 удалений

Просмотреть файл

@ -827,14 +827,70 @@ element.inViewport = function (el, x = undefined, y = undefined) {
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.
@ -884,7 +940,7 @@ element.isInteractable = function (el) {
* 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;
};
@ -928,14 +984,13 @@ element.getInViewCentrePoint = function (rect, win) {
*
* @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)) {
@ -952,10 +1007,7 @@ element.getInteractableElementTree = function (el, doc) {
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.

Просмотреть файл

@ -48,6 +48,30 @@ link.addEventListener("click", () => window.clicked = true);
""")
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."""
@ -57,26 +81,32 @@ class TestLegacyClick(MarionetteTestCase):
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")
@ -103,17 +133,7 @@ class TestClick(TestLegacyClick):
{"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")
@ -199,3 +219,36 @@ class TestClick(TestLegacyClick):
<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))

Просмотреть файл

@ -85,13 +85,13 @@ this.interaction = {};
* 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.
@ -103,33 +103,104 @@ this.interaction = {};
* @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();
}
@ -138,7 +209,7 @@ interaction.clickElement = function*(el, strict = false, specCompat = false) {
}
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);
});
@ -156,31 +227,14 @@ interaction.clickElement = function*(el, strict = false, specCompat = false) {
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.
*
@ -207,20 +261,47 @@ interaction.selectOption = function (el) {
}
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);
});
};
/**
@ -260,31 +341,6 @@ interaction.uploadFile = function* (el, path) {
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.
*