зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
b094d489ab
Коммит
287f6b6d18
|
@ -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.
|
||||
*
|
||||
|
|
Загрузка…
Ссылка в новой задаче