. * */ /** * 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. * * NoSuchElement exceptions are sometimes thrown instead of * StaleElementReference exceptions. This can happen when the Selenium2 driver * for Mink performs an action on an element through the WebDriver session * instead of directly through the WebDriver element. In that case, if the * element with the given ID does not exist, a NoSuchElement exception would be * thrown instead of a StaleElementReference exception, so those cases are * handled like StaleElementReference exceptions. * * 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. * * MoveTargetOutOfBounds exceptions are sometimes thrown instead of * ElementNotVisible exceptions. This can happen when the Selenium2 driver for * Mink moves the cursor on an element using the "moveto" method of the * WebDriver session, for example, before clicking on an element. In that case, * if the element is not visible, "moveto" would throw a MoveTargetOutOfBounds * exception instead of an ElementNotVisible exception, so those cases are * handled like ElementNotVisible exceptions. * * ElementNotInteractable exceptions are thrown in Selenium 3 when the command * needs to interact with an element but that is not possible. This could be a * transitive situation (for example, due to an animation), so the command is * executed again after a small timeout. * * 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). In a * similar way, MoveTargetOutOfBounds exceptions would be thrown again if * originally they were thrown because the element was visible but "out of * reach". ElementNotInteractable exceptions would be thrown again if it is not * possible to interact yet with the element after the wait (which could mean * that the test has to, indeed, fail, although it could mean too that the * automatic handling needs to be improved). * * 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 $this->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 bool 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 whether the wrapped element is checked or not. * * @return bool true if the wrapped element is checked, false otherwise. */ public function isChecked() { $commandCallback = function () { return $this->element->isChecked(); }; return $this->executeCommand($commandCallback, "check state 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"); } /** * Check the wrapped element. * * If automatically waits for the wrapped element to be visible (up to the * timeout set when finding it). */ public function check() { $commandCallback = function () { $this->element->check(); }; $this->executeCommand($commandCallback, "could not be checked"); } /** * uncheck the wrapped element. * * If automatically waits for the wrapped element to be visible (up to the * timeout set when finding it). */ public function uncheck() { $commandCallback = function () { $this->element->uncheck(); }; $this->executeCommand($commandCallback, "could not be unchecked"); } /** * Executes the given command. * * If a StaleElementReference or a NoSuchElement 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); } catch (\WebDriver\Exception\NoSuchElement $exception) { $this->printFailedCommandMessage($exception, $errorMessage); } $this->element = $this->elementFinder->find(); return $commandCallback(); } /** * Executes the given command on a visible element. * * If a StaleElementReference or a NoSuchElement exception is thrown the * wrapped element is found again and, then, the command is executed again. * If an ElementNotVisible or a MoveTargetOutOfBounds exception is thrown it * is waited for the wrapped element to be visible and, then, the command is * executed again. * If an ElementNotInteractable exception is thrown it is also waited for * the wrapped element to be visible. It is very likely that the element was * visible already, but it is not possible to easily check if the element * can be interacted with, retrying will be only useful if it was a * transitive situation that resolves itself with a wait (for example, due * to an animation) and waiting for the element to be visible will always * start with a wait. * * @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); } catch (\WebDriver\Exception\MoveTargetOutOfBounds $exception) { $this->printFailedCommandMessage($exception, $errorMessage); } catch (\Exception $exception) { // The "ElementNotInteractable" exception is not available yet in // the current "instaclick/php-webdriver" version, so it is thrown // as a generic exception with a specific message. if (stripos($exception->getMessage(), "element not interactable") === false) { throw $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); } }