diff options
author | Morris Jobke <hey@morrisjobke.de> | 2017-05-08 12:52:30 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-08 12:52:30 -0500 |
commit | 1a83f119250b603f4935d6e67d14ad42fb18c233 (patch) | |
tree | ff68a3917ff7fe74b73c466ae8641450d48034fa /tests | |
parent | 2d707fdfb5657908bb1c5018f978afb8be5c7563 (diff) | |
parent | 9313c9797fe1ef9349526175f72aedf0483dea39 (diff) | |
download | nextcloud-server-1a83f119250b603f4935d6e67d14ad42fb18c233.tar.gz nextcloud-server-1a83f119250b603f4935d6e67d14ad42fb18c233.zip |
Merge pull request #4718 from nextcloud/handle-stalled-or-invisible-elements-automatically-in-acceptance-tests
Handle stale or invisible elements automatically in acceptance tests
Diffstat (limited to 'tests')
-rw-r--r-- | tests/acceptance/features/core/Actor.php | 91 | ||||
-rw-r--r-- | tests/acceptance/features/core/ElementFinder.php | 205 | ||||
-rw-r--r-- | tests/acceptance/features/core/ElementWrapper.php | 275 |
3 files changed, 493 insertions, 78 deletions
diff --git a/tests/acceptance/features/core/Actor.php b/tests/acceptance/features/core/Actor.php index 3a57b7e6054..a87ccfb7737 100644 --- a/tests/acceptance/features/core/Actor.php +++ b/tests/acceptance/features/core/Actor.php @@ -42,6 +42,11 @@ * exception is thrown if the element is not found, and, optionally, it is * possible to try again to find the element several times before giving up. * + * The returned object is also a wrapper over the element itself that + * automatically handles common causes of failed commands, like clicking on a + * hidden element; in this case, the wrapper would wait for the element to be + * visible up to the timeout set to find the element. + * * The amount of time to wait before giving up is specified in each call to * find(). However, a general multiplier to be applied to every timeout can be * set using setFindTimeoutMultiplier(); this makes possible to retry longer @@ -150,6 +155,10 @@ class Actor { * before retrying is half a second. If the timeout is not 0 it will be * affected by the multiplier set using setFindTimeoutMultiplier(), if any. * + * When found, the element is returned wrapped in an ElementWrapper; the + * ElementWrapper handles common causes of failures when executing commands + * in an element, like clicking on a hidden element. + * * In any case, if the element, or its ancestors, can not be found a * NoSuchElementException is thrown. * @@ -158,90 +167,16 @@ class Actor { * most for the element to appear. * @param float $timeoutStep the number of seconds (decimals allowed) to * wait before trying to find the element again. - * @return \Behat\Mink\Element\Element the element found. + * @return ElementWrapper an ElementWrapper object for the element. * @throws NoSuchElementException if the element, or its ancestor, can not * be found. */ - public function find($elementLocator, $timeout = 0, $timeoutStep = 0.5) { + public function find(Locator $elementLocator, $timeout = 0, $timeoutStep = 0.5) { $timeout = $timeout * $this->findTimeoutMultiplier; - return $this->findInternal($elementLocator, $timeout, $timeoutStep); - } - - /** - * Finds an element in the Mink Session of this Actor. - * - * The timeout is not affected by the multiplier set using - * setFindTimeoutMultiplier(). - * - * @see find($elementLocator, $timeout, $timeoutStep) - */ - private function findInternal($elementLocator, $timeout, $timeoutStep) { - $element = null; - $selector = $elementLocator->getSelector(); - $locator = $elementLocator->getLocator(); - $ancestorElement = $this->findAncestorElement($elementLocator, $timeout, $timeoutStep); - - $findCallback = function() use (&$element, $selector, $locator, $ancestorElement) { - $element = $ancestorElement->find($selector, $locator); - - return $element !== null; - }; - if (!Utils::waitFor($findCallback, $timeout, $timeoutStep)) { - $message = $elementLocator->getDescription() . " could not be found"; - if ($timeout > 0) { - $message = $message . " after $timeout seconds"; - } - throw new NoSuchElementException($message); - } - - return $element; - } - - /** - * Returns the ancestor element from which the given locator will be looked - * for. - * - * If the ancestor of the given locator is another locator the element for - * the ancestor locator is found and returned. If the ancestor of the given - * locator is already an element that element is the one returned. If the - * given locator has no ancestor then the base document element is returned. - * - * The timeout is used only when finding the element for the ancestor - * locator; if the timeout expires a NoSuchElementException is thrown. - * - * @param Locator $elementLocator the locator for the element to get its - * ancestor. - * @param float $timeout the number of seconds (decimals allowed) to wait at - * most for the ancestor element to appear. - * @param float $timeoutStep the number of seconds (decimals allowed) to - * wait before trying to find the ancestor element again. - * @return \Behat\Mink\Element\Element the ancestor element found. - * @throws NoSuchElementException if the ancestor element can not be found. - */ - private function findAncestorElement($elementLocator, $timeout, $timeoutStep) { - $ancestorElement = $elementLocator->getAncestor(); - if ($ancestorElement instanceof Locator) { - try { - $ancestorElement = $this->findInternal($ancestorElement, $timeout, $timeoutStep); - } catch (NoSuchElementException $exception) { - // Little hack to show the stack of ancestor elements that could - // not be found, as Behat only shows the message of the last - // exception in the chain. - $message = $exception->getMessage() . "\n" . - $elementLocator->getDescription() . " could not be found"; - if ($timeout > 0) { - $message = $message . " after $timeout seconds"; - } - throw new NoSuchElementException($message, $exception); - } - } - - if ($ancestorElement === null) { - $ancestorElement = $this->getSession()->getPage(); - } + $elementFinder = new ElementFinder($this->session, $elementLocator, $timeout, $timeoutStep); - return $ancestorElement; + return new ElementWrapper($elementFinder); } /** diff --git a/tests/acceptance/features/core/ElementFinder.php b/tests/acceptance/features/core/ElementFinder.php new file mode 100644 index 00000000000..d075e9fe660 --- /dev/null +++ b/tests/acceptance/features/core/ElementFinder.php @@ -0,0 +1,205 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Command object to find Mink elements. + * + * The element locator is relative to its ancestor (either another locator or an + * actual element); if it has no ancestor then the base document element is + * used. + * + * Sometimes an element may not be found simply because it has not appeared yet; + * for those cases ElementFinder supports trying again to find the element + * several times before giving up. The timeout parameter controls how much time + * to wait, at most, to find the element; the timeoutStep parameter controls how + * much time to wait before trying again to find the element. If ancestor + * locators need to be found the timeout is applied individually to each one, + * that is, if the timeout is 10 seconds the method will wait up to 10 seconds + * to find the ancestor of the ancestor and, then, up to 10 seconds to find the + * ancestor and, then, up to 10 seconds to find the element. By default the + * timeout is 0, so the element and its ancestor will be looked for just once; + * the default time to wait before retrying is half a second. + * + * In any case, if the element, or its ancestors, can not be found a + * NoSuchElementException is thrown. + */ +class ElementFinder { + + /** + * Finds an element in the given Mink Session. + * + * @see ElementFinder + */ + private static function findInternal(\Behat\Mink\Session $session, Locator $elementLocator, $timeout, $timeoutStep) { + $element = null; + $selector = $elementLocator->getSelector(); + $locator = $elementLocator->getLocator(); + $ancestorElement = self::findAncestorElement($session, $elementLocator, $timeout, $timeoutStep); + + $findCallback = function() use (&$element, $selector, $locator, $ancestorElement) { + $element = $ancestorElement->find($selector, $locator); + + return $element !== null; + }; + if (!Utils::waitFor($findCallback, $timeout, $timeoutStep)) { + $message = $elementLocator->getDescription() . " could not be found"; + if ($timeout > 0) { + $message = $message . " after $timeout seconds"; + } + throw new NoSuchElementException($message); + } + + return $element; + } + + /** + * Returns the ancestor element from which the given locator will be looked + * for. + * + * If the ancestor of the given locator is another locator the element for + * the ancestor locator is found and returned. If the ancestor of the given + * locator is already an element that element is the one returned. If the + * given locator has no ancestor then the base document element is returned. + * + * The timeout is used only when finding the element for the ancestor + * locator; if the timeout expires a NoSuchElementException is thrown. + * + * @param \Behat\Mink\Session $session the Mink Session to get the ancestor + * element from. + * @param Locator $elementLocator the locator for the element to get its + * ancestor. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the ancestor element to appear. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before trying to find the ancestor element again. + * @return \Behat\Mink\Element\Element the ancestor element found. + * @throws NoSuchElementException if the ancestor element can not be found. + */ + private static function findAncestorElement(\Behat\Mink\Session $session, Locator $elementLocator, $timeout, $timeoutStep) { + $ancestorElement = $elementLocator->getAncestor(); + if ($ancestorElement instanceof Locator) { + try { + $ancestorElement = self::findInternal($session, $ancestorElement, $timeout, $timeoutStep); + } catch (NoSuchElementException $exception) { + // Little hack to show the stack of ancestor elements that could + // not be found, as Behat only shows the message of the last + // exception in the chain. + $message = $exception->getMessage() . "\n" . + $elementLocator->getDescription() . " could not be found"; + if ($timeout > 0) { + $message = $message . " after $timeout seconds"; + } + throw new NoSuchElementException($message, $exception); + } + } + + if ($ancestorElement === null) { + $ancestorElement = $session->getPage(); + } + + return $ancestorElement; + } + + /** + * @var \Behat\Mink\Session + */ + private $session; + + /** + * @param Locator + */ + private $elementLocator; + + /** + * @var float + */ + private $timeout; + + /** + * @var float + */ + private $timeoutStep; + + /** + * Creates a new ElementFinder. + * + * @param \Behat\Mink\Session $session the Mink Session to get the element + * from. + * @param Locator $elementLocator the locator for the element. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the element to appear. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before trying to find the element again. + */ + public function __construct(\Behat\Mink\Session $session, Locator $elementLocator, $timeout, $timeoutStep) { + $this->session = $session; + $this->elementLocator = $elementLocator; + $this->timeout = $timeout; + $this->timeoutStep = $timeoutStep; + } + + /** + * Returns the description of the element to find. + * + * @return string the description of the element to find. + */ + public function getDescription() { + return $this->elementLocator->getDescription(); + } + + /** + * Returns the timeout. + * + * @return float the number of seconds (decimals allowed) to wait at most + * for the element to appear. + */ + public function getTimeout() { + return $this->timeout; + } + + /** + * Returns the timeout step. + * + * @return float the number of seconds (decimals allowed) to wait before + * trying to find the element again. + */ + public function getTimeoutStep() { + return $this->timeoutStep; + } + + /** + * Finds an element using the parameters set in the constructor of this + * ElementFinder. + * + * If the element, or its ancestors, can not be found a + * NoSuchElementException is thrown. + * + * @return \Behat\Mink\Element\Element the element found. + * @throws NoSuchElementException if the element, or its ancestor, can not + * be found. + */ + public function find() { + return self::findInternal($this->session, $this->elementLocator, $this->timeout, $this->timeoutStep); + } + +} diff --git a/tests/acceptance/features/core/ElementWrapper.php b/tests/acceptance/features/core/ElementWrapper.php new file mode 100644 index 00000000000..6b730903f6c --- /dev/null +++ b/tests/acceptance/features/core/ElementWrapper.php @@ -0,0 +1,275 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Wrapper to automatically handle failed commands on Mink elements. + * + * Commands executed on Mink elements may fail for several reasons. The + * ElementWrapper frees the caller of the commands from handling the most common + * reasons of failure. + * + * StaleElementReference exceptions are thrown when the command is executed on + * an element that is no longer attached to the DOM. This can happen even in + * a chained call like "$actor->find($locator)->click()"; in the milliseconds + * between finding the element and clicking it the element could have been + * removed from the page (for example, if a previous interaction with the page + * started an asynchronous update of the DOM). Every command executed through + * the ElementWrapper is guarded against StaleElementReference exceptions; if + * the element is stale it is found again using the same parameters to find it + * in the first place. + * + * ElementNotVisible exceptions are thrown when the command requires the element + * to be visible but the element is not. Finding an element only guarantees that + * (at that time) the element is attached to the DOM, but it does not provide + * any guarantee regarding its visibility. Due to that, a call like + * "$actor->find($locator)->click()" can fail if the element was hidden and + * meant to be made visible by a previous interaction with the page, but that + * interaction triggered an asynchronous update that was not finished when the + * click command is executed. All commands executed through the ElementWrapper + * that require the element to be visible are guarded against ElementNotVisible + * exceptions; if the element is not visible it is waited for it to be visible + * up to the timeout set to find it. + * + * Despite the automatic handling it is possible for the commands to throw those + * exceptions when they are executed again; this class does not handle cases + * like an element becoming stale several times in a row (uncommon) or an + * element not becoming visible before the timeout expires (which would mean + * that the timeout is too short or that the test has to, indeed, fail). + * + * If needed, automatically handling failed commands can be disabled calling + * "doNotHandleFailedCommands()"; as it returns the ElementWrapper it can be + * chained with the command to execute (but note that automatically handling + * failed commands will still be disabled if further commands are executed on + * the ElementWrapper). + */ +class ElementWrapper { + + /** + * @var ElementFinder + */ + private $elementFinder; + + /** + * @var \Behat\Mink\Element\Element + */ + private $element; + + /** + * @param boolean + */ + private $handleFailedCommands; + + /** + * Creates a new ElementWrapper. + * + * The wrapped element is found in the constructor itself using the + * ElementFinder. + * + * @param ElementFinder $elementFinder the command object to find the + * wrapped element. + * @throws NoSuchElementException if the element, or its ancestor, can not + * be found. + */ + public function __construct(ElementFinder $elementFinder) { + $this->elementFinder = $elementFinder; + $this->element = $elementFinder->find(); + $this->handleFailedCommands = true; + } + + /** + * Returns the raw Mink element. + * + * @return \Behat\Mink\Element\Element the wrapped element. + */ + public function getWrappedElement() { + return $element; + } + + /** + * Prevents the automatic handling of failed commands. + * + * @return ElementWrapper this ElementWrapper. + */ + public function doNotHandleFailedCommands() { + $this->handleFailedCommands = false; + + return $this; + } + + /** + * Returns whether the wrapped element is visible or not. + * + * @return boolbean true if the wrapped element is visible, false otherwise. + */ + public function isVisible() { + $commandCallback = function() { + return $this->element->isVisible(); + }; + return $this->executeCommand($commandCallback, "visibility could not be got"); + } + + /** + * Returns the text of the wrapped element. + * + * If the wrapped element is not visible the returned text is an empty + * string. + * + * @return string the text of the wrapped element, or an empty string if it + * is not visible. + */ + public function getText() { + $commandCallback = function() { + return $this->element->getText(); + }; + return $this->executeCommand($commandCallback, "text could not be got"); + } + + /** + * Returns the value of the wrapped element. + * + * The value can be got even if the wrapped element is not visible. + * + * @return string the value of the wrapped element. + */ + public function getValue() { + $commandCallback = function() { + return $this->element->getValue(); + }; + return $this->executeCommand($commandCallback, "value could not be got"); + } + + /** + * Sets the given value on the wrapped element. + * + * If automatically waits for the wrapped element to be visible (up to the + * timeout set when finding it). + * + * @param string $value the value to set. + */ + public function setValue($value) { + $commandCallback = function() use ($value) { + $this->element->setValue($value); + }; + $this->executeCommandOnVisibleElement($commandCallback, "value could not be set"); + } + + /** + * Clicks on the wrapped element. + * + * If automatically waits for the wrapped element to be visible (up to the + * timeout set when finding it). + */ + public function click() { + $commandCallback = function() { + $this->element->click(); + }; + $this->executeCommandOnVisibleElement($commandCallback, "could not be clicked"); + } + + /** + * Executes the given command. + * + * If a StaleElementReference exception is thrown the wrapped element is + * found again and, then, the command is executed again. + * + * @param \Closure $commandCallback the command to execute. + * @param string $errorMessage an error message that describes the failed + * command (appended to the description of the element). + */ + private function executeCommand(\Closure $commandCallback, $errorMessage) { + if (!$this->handleFailedCommands) { + return $commandCallback(); + } + + try { + return $commandCallback(); + } catch (\WebDriver\Exception\StaleElementReference $exception) { + $this->printFailedCommandMessage($exception, $errorMessage); + } + + $this->element = $this->elementFinder->find(); + + return $commandCallback(); + } + + /** + * Executes the given command on a visible element. + * + * If a StaleElementReference exception is thrown the wrapped element is + * found again and, then, the command is executed again. If an + * ElementNotVisible exception is thrown it is waited for the wrapped + * element to be visible and, then, the command is executed again. + * + * @param \Closure $commandCallback the command to execute. + * @param string $errorMessage an error message that describes the failed + * command (appended to the description of the element). + */ + private function executeCommandOnVisibleElement(\Closure $commandCallback, $errorMessage) { + if (!$this->handleFailedCommands) { + return $commandCallback(); + } + + try { + return $this->executeCommand($commandCallback, $errorMessage); + } catch (\WebDriver\Exception\ElementNotVisible $exception) { + $this->printFailedCommandMessage($exception, $errorMessage); + } + + $this->waitForElementToBeVisible(); + + return $commandCallback(); + } + + /** + * Prints information about the failed command. + * + * @param \Exception exception the exception thrown by the command. + * @param string $errorMessage an error message that describes the failed + * command (appended to the description of the locator of the element). + */ + private function printFailedCommandMessage(\Exception $exception, $errorMessage) { + echo $this->elementFinder->getDescription() . " " . $errorMessage . "\n"; + echo "Exception message: " . $exception->getMessage() . "\n"; + echo "Trying again\n"; + } + + /** + * Waits for the wrapped element to be visible. + * + * This method waits up to the timeout used when finding the wrapped + * element; therefore, it may return when the element is still not visible. + * + * @return boolean true if the element is visible after the wait, false + * otherwise. + */ + private function waitForElementToBeVisible() { + $isVisibleCallback = function() { + return $this->isVisible(); + }; + $timeout = $this->elementFinder->getTimeout(); + $timeoutStep = $this->elementFinder->getTimeoutStep(); + + return Utils::waitFor($isVisibleCallback, $timeout, $timeoutStep); + } + +} |