ElementWrapper.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. <?php
  2. /**
  3. *
  4. * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com)
  5. *
  6. * @license GNU AGPL version 3 or any later version
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU Affero General Public License as
  10. * published by the Free Software Foundation, either version 3 of the
  11. * License, or (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. *
  21. */
  22. /**
  23. * Wrapper to automatically handle failed commands on Mink elements.
  24. *
  25. * Commands executed on Mink elements may fail for several reasons. The
  26. * ElementWrapper frees the caller of the commands from handling the most common
  27. * reasons of failure.
  28. *
  29. * StaleElementReference exceptions are thrown when the command is executed on
  30. * an element that is no longer attached to the DOM. This can happen even in
  31. * a chained call like "$actor->find($locator)->click()"; in the milliseconds
  32. * between finding the element and clicking it the element could have been
  33. * removed from the page (for example, if a previous interaction with the page
  34. * started an asynchronous update of the DOM). Every command executed through
  35. * the ElementWrapper is guarded against StaleElementReference exceptions; if
  36. * the element is stale it is found again using the same parameters to find it
  37. * in the first place.
  38. *
  39. * NoSuchElement exceptions are sometimes thrown instead of
  40. * StaleElementReference exceptions. This can happen when the Selenium2 driver
  41. * for Mink performs an action on an element through the WebDriver session
  42. * instead of directly through the WebDriver element. In that case, if the
  43. * element with the given ID does not exist, a NoSuchElement exception would be
  44. * thrown instead of a StaleElementReference exception, so those cases are
  45. * handled like StaleElementReference exceptions.
  46. *
  47. * ElementNotVisible exceptions are thrown when the command requires the element
  48. * to be visible but the element is not. Finding an element only guarantees that
  49. * (at that time) the element is attached to the DOM, but it does not provide
  50. * any guarantee regarding its visibility. Due to that, a call like
  51. * "$actor->find($locator)->click()" can fail if the element was hidden and
  52. * meant to be made visible by a previous interaction with the page, but that
  53. * interaction triggered an asynchronous update that was not finished when the
  54. * click command is executed. All commands executed through the ElementWrapper
  55. * that require the element to be visible are guarded against ElementNotVisible
  56. * exceptions; if the element is not visible it is waited for it to be visible
  57. * up to the timeout set to find it.
  58. *
  59. * MoveTargetOutOfBounds exceptions are sometimes thrown instead of
  60. * ElementNotVisible exceptions. This can happen when the Selenium2 driver for
  61. * Mink moves the cursor on an element using the "moveto" method of the
  62. * WebDriver session, for example, before clicking on an element. In that case,
  63. * if the element is not visible, "moveto" would throw a MoveTargetOutOfBounds
  64. * exception instead of an ElementNotVisible exception, so those cases are
  65. * handled like ElementNotVisible exceptions.
  66. *
  67. * ElementNotInteractable exceptions are thrown in Selenium 3 when the command
  68. * needs to interact with an element but that is not possible. This could be a
  69. * transitive situation (for example, due to an animation), so the command is
  70. * executed again after a small timeout.
  71. *
  72. * Despite the automatic handling it is possible for the commands to throw those
  73. * exceptions when they are executed again; this class does not handle cases
  74. * like an element becoming stale several times in a row (uncommon) or an
  75. * element not becoming visible before the timeout expires (which would mean
  76. * that the timeout is too short or that the test has to, indeed, fail). In a
  77. * similar way, MoveTargetOutOfBounds exceptions would be thrown again if
  78. * originally they were thrown because the element was visible but "out of
  79. * reach". ElementNotInteractable exceptions would be thrown again if it is not
  80. * possible to interact yet with the element after the wait (which could mean
  81. * that the test has to, indeed, fail, although it could mean too that the
  82. * automatic handling needs to be improved).
  83. *
  84. * If needed, automatically handling failed commands can be disabled calling
  85. * "doNotHandleFailedCommands()"; as it returns the ElementWrapper it can be
  86. * chained with the command to execute (but note that automatically handling
  87. * failed commands will still be disabled if further commands are executed on
  88. * the ElementWrapper).
  89. */
  90. class ElementWrapper {
  91. /**
  92. * @var ElementFinder
  93. */
  94. private $elementFinder;
  95. /**
  96. * @var \Behat\Mink\Element\Element
  97. */
  98. private $element;
  99. /**
  100. * @param boolean
  101. */
  102. private $handleFailedCommands;
  103. /**
  104. * Creates a new ElementWrapper.
  105. *
  106. * The wrapped element is found in the constructor itself using the
  107. * ElementFinder.
  108. *
  109. * @param ElementFinder $elementFinder the command object to find the
  110. * wrapped element.
  111. * @throws NoSuchElementException if the element, or its ancestor, can not
  112. * be found.
  113. */
  114. public function __construct(ElementFinder $elementFinder) {
  115. $this->elementFinder = $elementFinder;
  116. $this->element = $elementFinder->find();
  117. $this->handleFailedCommands = true;
  118. }
  119. /**
  120. * Returns the raw Mink element.
  121. *
  122. * @return \Behat\Mink\Element\Element the wrapped element.
  123. */
  124. public function getWrappedElement() {
  125. return $this->element;
  126. }
  127. /**
  128. * Prevents the automatic handling of failed commands.
  129. *
  130. * @return ElementWrapper this ElementWrapper.
  131. */
  132. public function doNotHandleFailedCommands() {
  133. $this->handleFailedCommands = false;
  134. return $this;
  135. }
  136. /**
  137. * Returns whether the wrapped element is visible or not.
  138. *
  139. * @return bool true if the wrapped element is visible, false otherwise.
  140. */
  141. public function isVisible() {
  142. $commandCallback = function () {
  143. return $this->element->isVisible();
  144. };
  145. return $this->executeCommand($commandCallback, "visibility could not be got");
  146. }
  147. /**
  148. * Returns whether the wrapped element is checked or not.
  149. *
  150. * @return bool true if the wrapped element is checked, false otherwise.
  151. */
  152. public function isChecked() {
  153. $commandCallback = function () {
  154. return $this->element->isChecked();
  155. };
  156. return $this->executeCommand($commandCallback, "check state could not be got");
  157. }
  158. /**
  159. * Returns the text of the wrapped element.
  160. *
  161. * If the wrapped element is not visible the returned text is an empty
  162. * string.
  163. *
  164. * @return string the text of the wrapped element, or an empty string if it
  165. * is not visible.
  166. */
  167. public function getText() {
  168. $commandCallback = function () {
  169. return $this->element->getText();
  170. };
  171. return $this->executeCommand($commandCallback, "text could not be got");
  172. }
  173. /**
  174. * Returns the value of the wrapped element.
  175. *
  176. * The value can be got even if the wrapped element is not visible.
  177. *
  178. * @return string the value of the wrapped element.
  179. */
  180. public function getValue() {
  181. $commandCallback = function () {
  182. return $this->element->getValue();
  183. };
  184. return $this->executeCommand($commandCallback, "value could not be got");
  185. }
  186. /**
  187. * Sets the given value on the wrapped element.
  188. *
  189. * If automatically waits for the wrapped element to be visible (up to the
  190. * timeout set when finding it).
  191. *
  192. * @param string $value the value to set.
  193. */
  194. public function setValue($value) {
  195. $commandCallback = function () use ($value) {
  196. $this->element->setValue($value);
  197. };
  198. $this->executeCommandOnVisibleElement($commandCallback, "value could not be set");
  199. }
  200. /**
  201. * Clicks on the wrapped element.
  202. *
  203. * If automatically waits for the wrapped element to be visible (up to the
  204. * timeout set when finding it).
  205. */
  206. public function click() {
  207. $commandCallback = function () {
  208. $this->element->click();
  209. };
  210. $this->executeCommandOnVisibleElement($commandCallback, "could not be clicked");
  211. }
  212. /**
  213. * Check the wrapped element.
  214. *
  215. * If automatically waits for the wrapped element to be visible (up to the
  216. * timeout set when finding it).
  217. */
  218. public function check() {
  219. $commandCallback = function () {
  220. $this->element->check();
  221. };
  222. $this->executeCommand($commandCallback, "could not be checked");
  223. }
  224. /**
  225. * uncheck the wrapped element.
  226. *
  227. * If automatically waits for the wrapped element to be visible (up to the
  228. * timeout set when finding it).
  229. */
  230. public function uncheck() {
  231. $commandCallback = function () {
  232. $this->element->uncheck();
  233. };
  234. $this->executeCommand($commandCallback, "could not be unchecked");
  235. }
  236. /**
  237. * Executes the given command.
  238. *
  239. * If a StaleElementReference or a NoSuchElement exception is thrown the
  240. * wrapped element is found again and, then, the command is executed again.
  241. *
  242. * @param \Closure $commandCallback the command to execute.
  243. * @param string $errorMessage an error message that describes the failed
  244. * command (appended to the description of the element).
  245. */
  246. private function executeCommand(\Closure $commandCallback, $errorMessage) {
  247. if (!$this->handleFailedCommands) {
  248. return $commandCallback();
  249. }
  250. try {
  251. return $commandCallback();
  252. } catch (\WebDriver\Exception\StaleElementReference $exception) {
  253. $this->printFailedCommandMessage($exception, $errorMessage);
  254. } catch (\WebDriver\Exception\NoSuchElement $exception) {
  255. $this->printFailedCommandMessage($exception, $errorMessage);
  256. }
  257. $this->element = $this->elementFinder->find();
  258. return $commandCallback();
  259. }
  260. /**
  261. * Executes the given command on a visible element.
  262. *
  263. * If a StaleElementReference or a NoSuchElement exception is thrown the
  264. * wrapped element is found again and, then, the command is executed again.
  265. * If an ElementNotVisible or a MoveTargetOutOfBounds exception is thrown it
  266. * is waited for the wrapped element to be visible and, then, the command is
  267. * executed again.
  268. * If an ElementNotInteractable exception is thrown it is also waited for
  269. * the wrapped element to be visible. It is very likely that the element was
  270. * visible already, but it is not possible to easily check if the element
  271. * can be interacted with, retrying will be only useful if it was a
  272. * transitive situation that resolves itself with a wait (for example, due
  273. * to an animation) and waiting for the element to be visible will always
  274. * start with a wait.
  275. *
  276. * @param \Closure $commandCallback the command to execute.
  277. * @param string $errorMessage an error message that describes the failed
  278. * command (appended to the description of the element).
  279. */
  280. private function executeCommandOnVisibleElement(\Closure $commandCallback, $errorMessage) {
  281. if (!$this->handleFailedCommands) {
  282. return $commandCallback();
  283. }
  284. try {
  285. return $this->executeCommand($commandCallback, $errorMessage);
  286. } catch (\WebDriver\Exception\ElementNotVisible $exception) {
  287. $this->printFailedCommandMessage($exception, $errorMessage);
  288. } catch (\WebDriver\Exception\MoveTargetOutOfBounds $exception) {
  289. $this->printFailedCommandMessage($exception, $errorMessage);
  290. } catch (\Exception $exception) {
  291. // The "ElementNotInteractable" exception is not available yet in
  292. // the current "instaclick/php-webdriver" version, so it is thrown
  293. // as a generic exception with a specific message.
  294. if (stripos($exception->getMessage(), "element not interactable") === false) {
  295. throw $exception;
  296. }
  297. $this->printFailedCommandMessage($exception, $errorMessage);
  298. }
  299. $this->waitForElementToBeVisible();
  300. return $commandCallback();
  301. }
  302. /**
  303. * Prints information about the failed command.
  304. *
  305. * @param \Exception exception the exception thrown by the command.
  306. * @param string $errorMessage an error message that describes the failed
  307. * command (appended to the description of the locator of the element).
  308. */
  309. private function printFailedCommandMessage(\Exception $exception, $errorMessage) {
  310. echo $this->elementFinder->getDescription() . " " . $errorMessage . "\n";
  311. echo "Exception message: " . $exception->getMessage() . "\n";
  312. echo "Trying again\n";
  313. }
  314. /**
  315. * Waits for the wrapped element to be visible.
  316. *
  317. * This method waits up to the timeout used when finding the wrapped
  318. * element; therefore, it may return when the element is still not visible.
  319. *
  320. * @return boolean true if the element is visible after the wait, false
  321. * otherwise.
  322. */
  323. private function waitForElementToBeVisible() {
  324. $isVisibleCallback = function () {
  325. return $this->isVisible();
  326. };
  327. $timeout = $this->elementFinder->getTimeout();
  328. $timeoutStep = $this->elementFinder->getTimeoutStep();
  329. return Utils::waitFor($isVisibleCallback, $timeout, $timeoutStep);
  330. }
  331. }