Merge branch 'MDL-43784-master' of git://github.com/FMCorz/moodle
[moodle.git] / lib / behat / behat_base.php
CommitLineData
d4322e38
DM
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Base class of all steps definitions.
19 *
20 * This script is only called from Behat as part of it's integration
21 * in Moodle.
22 *
23 * @package core
24 * @category test
25 * @copyright 2012 David MonllaĆ³
26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27 */
28
29// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
30
ff48a235 31use Behat\Mink\Exception\ExpectationException as ExpectationException,
fcb6ed47
MG
32 Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException,
33 Behat\Mink\Element\NodeElement as NodeElement;
ff48a235 34
d4322e38
DM
35/**
36 * Steps definitions base class.
37 *
38 * To extend by the steps definitions of the different Moodle components.
39 *
40 * It can not contain steps definitions to avoid duplicates, only utility
41 * methods shared between steps.
42 *
fcb6ed47
MG
43 * @method NodeElement find_field(string $locator) Finds a form element
44 * @method NodeElement find_button(string $locator) Finds a form input submit element or a button
45 * @method NodeElement find_link(string $locator) Finds a link on a page
46 * @method NodeElement find_file(string $locator) Finds a forum input file element
47 *
d4322e38
DM
48 * @package core
49 * @category test
50 * @copyright 2012 David MonllaĆ³
51 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
52 */
53class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
54
74c78e74
DM
55 /**
56 * Small timeout.
57 *
58 * A reduced timeout for cases where self::TIMEOUT is too much
59 * and a simple $this->getSession()->getPage()->find() could not
60 * be enough.
61 */
62 const REDUCED_TIMEOUT = 2;
63
d4322e38
DM
64 /**
65 * The timeout for each Behat step (load page, wait for an element to load...).
66 */
845ceaff 67 const TIMEOUT = 6;
d1e55a47
DM
68
69 /**
70 * And extended timeout for specific cases.
71 */
72 const EXTENDED_TIMEOUT = 10;
73
d1e55a47
DM
74 /**
75 * The JS code to check that the page is ready.
76 */
77 const PAGE_READY_JS = '(M && M.util && M.util.pending_js && !Boolean(M.util.pending_js.length)) && (document.readyState === "complete")';
d4322e38 78
786ea937
DM
79 /**
80 * Locates url, based on provided path.
81 * Override to provide custom routing mechanism.
82 *
83 * @see Behat\MinkExtension\Context\MinkContext
84 * @param string $path
85 * @return string
86 */
40923977 87 protected function locate_path($path) {
46ac40cd
DM
88 $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/';
89 return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
786ea937
DM
90 }
91
ff48a235 92 /**
f8f1bdc3 93 * Returns the first matching element.
ff48a235
DM
94 *
95 * @link http://mink.behat.org/#traverse-the-page-selectors
ff48a235
DM
96 * @param string $selector The selector type (css, xpath, named...)
97 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
40923977 98 * @param Exception $exception Otherwise we throw exception with generic info
072f67fc 99 * @param NodeElement $node Spins around certain DOM node instead of the whole page
74c78e74 100 * @param int $timeout Forces a specific time out (in seconds).
ff48a235
DM
101 * @return NodeElement
102 */
74c78e74 103 protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
ff48a235 104
f8f1bdc3 105 // Returns the first match.
74c78e74 106 $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
f8f1bdc3
DM
107 return count($items) ? reset($items) : null;
108 }
109
110 /**
111 * Returns all matching elements.
112 *
113 * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method.
114 *
115 * @link http://mink.behat.org/#traverse-the-page-selectors
116 * @param string $selector The selector type (css, xpath, named...)
117 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
118 * @param Exception $exception Otherwise we throw expcetion with generic info
119 * @param NodeElement $node Spins around certain DOM node instead of the whole page
74c78e74 120 * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied.
f8f1bdc3
DM
121 * @return array NodeElements list
122 */
74c78e74 123 protected function find_all($selector, $locator, $exception = false, $node = false, $timeout = false) {
f8f1bdc3 124
ff48a235
DM
125 // Generic info.
126 if (!$exception) {
127
128 // With named selectors we can be more specific.
129 if ($selector == 'named') {
130 $exceptiontype = $locator[0];
131 $exceptionlocator = $locator[1];
0a7f6a02
DM
132
133 // If we are in a @javascript session all contents would be displayed as HTML characters.
eff79d53 134 if ($this->running_javascript()) {
0a7f6a02
DM
135 $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
136 }
137
ff48a235
DM
138 } else {
139 $exceptiontype = $selector;
140 $exceptionlocator = $locator;
141 }
142
143 $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator);
144 }
145
072f67fc
DM
146 $params = array('selector' => $selector, 'locator' => $locator);
147 // Pushing $node if required.
148 if ($node) {
149 $params['node'] = $node;
150 }
151
74c78e74
DM
152 // How much we will be waiting for the element to appear.
153 if (!$timeout) {
154 $timeout = self::TIMEOUT;
155 $microsleep = false;
156 } else {
157 // Spinning each 0.1 seconds if the timeout was forced as we understand
158 // that is a special case and is good to refine the performance as much
159 // as possible.
160 $microsleep = true;
161 }
162
ff48a235
DM
163 // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception.
164 return $this->spin(
165 function($context, $args) {
072f67fc
DM
166
167 // If no DOM node provided look in all the page.
168 if (empty($args['node'])) {
f8f1bdc3 169 return $context->getSession()->getPage()->findAll($args['selector'], $args['locator']);
072f67fc
DM
170 }
171
172 // For nodes contained in other nodes we can not use the basic named selectors
173 // as they include unions and they would look for matches in the DOM root.
174 $elementxpath = $context->getSession()->getSelectorsHandler()->selectorToXpath($args['selector'], $args['locator']);
175
176 // Split the xpath in unions and prefix them with the container xpath.
177 $unions = explode('|', $elementxpath);
178 foreach ($unions as $key => $union) {
179 $union = trim($union);
180
181 // We are in the container node.
182 if (strpos($union, '.') === 0) {
183 $union = substr($union, 1);
3db1aec5
DM
184 } else if (strpos($union, '/') !== 0) {
185 // Adding the path separator in case it is not there.
186 $union = '/' . $union;
072f67fc
DM
187 }
188 $unions[$key] = $args['node']->getXpath() . $union;
189 }
190
191 // We can not use usual Element::find() as it prefixes with DOM root.
f8f1bdc3 192 return $context->getSession()->getDriver()->find(implode('|', $unions));
ff48a235 193 },
072f67fc 194 $params,
74c78e74
DM
195 $timeout,
196 $exception,
197 $microsleep
46ac40cd 198 );
ff48a235
DM
199 }
200
201 /**
202 * Finds DOM nodes in the page using named selectors.
203 *
204 * The point of using this method instead of Mink ones is the spin
205 * method of behat_base::find() that looks for the element until it
206 * is available or it timeouts, this avoids the false failures received
207 * when selenium tries to execute commands on elements that are not
208 * ready to be used.
209 *
210 * All steps that requires elements to be available before interact with
211 * them should use one of the find* methods.
212 *
213 * The methods calls requires a {'find_' . $elementtype}($locator)
214 * format, like find_link($locator), find_select($locator),
215 * find_button($locator)...
216 *
217 * @link http://mink.behat.org/#named-selectors
218 * @throws coding_exception
46ac40cd 219 * @param string $name The name of the called method
ff48a235
DM
220 * @param mixed $arguments
221 * @return NodeElement
222 */
223 public function __call($name, $arguments) {
224
225 if (substr($name, 0, 5) !== 'find_') {
226 throw new coding_exception('The "' . $name . '" method does not exist');
227 }
228
229 // Only the named selector identifier.
230 $cleanname = substr($name, 5);
231
232 // All named selectors shares the interface.
233 if (count($arguments) !== 1) {
234 throw new coding_exception('The "' . $cleanname . '" named selector needs the locator as it\'s single argument');
235 }
236
237 // Redirecting execution to the find method with the specified selector.
238 // It will detect if it's pointing to an unexisting named selector.
239 return $this->find('named',
240 array(
241 $cleanname,
242 $this->getSession()->getSelectorsHandler()->xpathLiteral($arguments[0])
243 )
244 );
245 }
246
49d91129
DM
247 /**
248 * Escapes the double quote character.
249 *
250 * Double quote is the argument delimiter, it can be escaped
251 * with a backslash, but we auto-remove this backslashes
252 * before the step execution, this method is useful when using
253 * arguments as arguments for other steps.
254 *
255 * @param string $string
256 * @return string
257 */
258 public function escape($string) {
259 return str_replace('"', '\"', $string);
260 }
261
786ea937
DM
262 /**
263 * Executes the passed closure until returns true or time outs.
264 *
265 * In most cases the document.readyState === 'complete' will be enough, but sometimes JS
266 * requires more time to be completely loaded or an element to be visible or whatever is required to
267 * perform some action on an element; this method receives a closure which should contain the
268 * required statements to ensure the step definition actions and assertions have all their needs
269 * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the
270 * closure to the caller.
271 *
272 * The closures requirements to work well with this spin method are:
273 * - Must return false, null or '' if something goes wrong
274 * - Must return something != false if finishes as expected, this will be the (mixed) value
275 * returned by spin()
276 *
ff48a235 277 * The arguments of the closure are mixed, use $args depending on your needs.
786ea937 278 *
ff48a235 279 * You can provide an exception to give more accurate feedback to tests writers, otherwise the
c51c3b55 280 * closure exception will be used, but you must provide an exception if the closure does not throw
ff48a235
DM
281 * an exception.
282 *
8e5f86bc
SH
283 * @throws Exception If it timeouts without receiving something != false from the closure
284 * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method)
285 * @param mixed $args Arguments to pass to the closure
286 * @param int $timeout Timeout in seconds
287 * @param Exception $exception The exception to throw in case it time outs.
288 * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds.
786ea937
DM
289 * @return mixed The value returned by the closure
290 */
8e5f86bc 291 protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) {
786ea937
DM
292
293 // Using default timeout which is pretty high.
294 if (!$timeout) {
295 $timeout = self::TIMEOUT;
296 }
8e5f86bc
SH
297 if ($microsleep) {
298 // Will sleep 1/10th of a second by default for self::TIMEOUT seconds.
299 $loops = $timeout * 10;
300 } else {
301 // Will sleep for self::TIMEOUT seconds.
302 $loops = $timeout;
303 }
786ea937 304
8e5f86bc 305 for ($i = 0; $i < $loops; $i++) {
786ea937
DM
306 // We catch the exception thrown by the step definition to execute it again.
307 try {
786ea937 308 // We don't check with !== because most of the time closures will return
ff48a235
DM
309 // direct Behat methods returns and we are not sure it will be always (bool)false
310 // if it just runs the behat method without returning anything $return == null.
8e5f86bc 311 if ($return = call_user_func($lambda, $this, $args)) {
786ea937
DM
312 return $return;
313 }
46ac40cd 314 } catch (Exception $e) {
ff48a235
DM
315 // We would use the first closure exception if no exception has been provided.
316 if (!$exception) {
317 $exception = $e;
318 }
786ea937
DM
319 // We wait until no exception is thrown or timeout expires.
320 continue;
321 }
322
8e5f86bc
SH
323 if ($microsleep) {
324 usleep(100000);
325 } else {
326 sleep(1);
327 }
786ea937
DM
328 }
329
ff48a235
DM
330 // Using coding_exception as is a development issue if no exception has been provided.
331 if (!$exception) {
8e5f86bc 332 $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception');
ff48a235
DM
333 }
334
786ea937
DM
335 // Throwing exception to the user.
336 throw $exception;
337 }
338
40923977
DM
339 /**
340 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
341 *
072f67fc
DM
342 * Use behat_base::get_text_selector_node() for text-based selectors.
343 *
40923977
DM
344 * @throws ElementNotFoundException Thrown by behat_base::find
345 * @param string $selectortype
346 * @param string $element
347 * @return NodeElement
348 */
349 protected function get_selected_node($selectortype, $element) {
350
351 // Getting Mink selector and locator.
352 list($selector, $locator) = $this->transform_selector($selectortype, $element);
353
354 // Returns the NodeElement.
355 return $this->find($selector, $locator);
356 }
357
072f67fc
DM
358 /**
359 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
360 *
361 * @throws ElementNotFoundException Thrown by behat_base::find
362 * @param string $selectortype
363 * @param string $element
364 * @return NodeElement
365 */
366 protected function get_text_selector_node($selectortype, $element) {
367
368 // Getting Mink selector and locator.
369 list($selector, $locator) = $this->transform_text_selector($selectortype, $element);
370
371 // Returns the NodeElement.
372 return $this->find($selector, $locator);
373 }
374
375 /**
376 * Gets the requested element inside the specified container.
377 *
378 * @throws ElementNotFoundException Thrown by behat_base::find
379 * @param mixed $selectortype The element selector type.
380 * @param mixed $element The element locator.
381 * @param mixed $containerselectortype The container selector type.
382 * @param mixed $containerelement The container locator.
383 * @return NodeElement
384 */
385 protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) {
386
387 // Gets the container, it will always be text based.
388 $containernode = $this->get_text_selector_node($containerselectortype, $containerelement);
389
390 list($selector, $locator) = $this->transform_selector($selectortype, $element);
391
392 // Specific exception giving info about where can't we find the element.
393 $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"';
394 $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg);
395
396 // Looks for the requested node inside the container node.
397 return $this->find($selector, $locator, $exception, $containernode);
398 }
399
40923977
DM
400 /**
401 * Transforms from step definition's argument style to Mink format.
402 *
403 * Mink has 3 different selectors css, xpath and named, where named
404 * selectors includes link, button, field... to simplify and group multiple
405 * steps in one we use the same interface, considering all link, buttons...
406 * at the same level as css selectors and xpath; this method makes the
407 * conversion from the arguments received by the steps to the selectors and locators
408 * required to interact with Mink.
409 *
410 * @throws ExpectationException
411 * @param string $selectortype It can be css, xpath or any of the named selectors.
412 * @param string $element The locator (or string) we are looking for.
413 * @return array Contains the selector and the locator expected by Mink.
414 */
415 protected function transform_selector($selectortype, $element) {
416
17344d4c
DM
417 // Here we don't know if an allowed text selector is being used.
418 $selectors = behat_selectors::get_allowed_selectors();
419 if (!isset($selectors[$selectortype])) {
40923977
DM
420 throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession());
421 }
422
17344d4c 423 return behat_selectors::get_behat_selector($selectortype, $element, $this->getSession());
40923977
DM
424 }
425
072f67fc
DM
426 /**
427 * Transforms from step definition's argument style to Mink format.
428 *
429 * Delegates all the process to behat_base::transform_selector() checking
430 * the provided $selectortype.
431 *
432 * @throws ExpectationException
433 * @param string $selectortype It can be css, xpath or any of the named selectors.
434 * @param string $element The locator (or string) we are looking for.
435 * @return array Contains the selector and the locator expected by Mink.
436 */
437 protected function transform_text_selector($selectortype, $element) {
438
17344d4c
DM
439 $selectors = behat_selectors::get_allowed_text_selectors();
440 if (empty($selectors[$selectortype])) {
072f67fc
DM
441 throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession());
442 }
443
444 return $this->transform_selector($selectortype, $element);
445 }
446
eff79d53
DM
447 /**
448 * Returns whether the scenario is running in a browser that can run Javascript or not.
449 *
450 * @return boolean
451 */
452 protected function running_javascript() {
28abad1a 453 return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
eff79d53
DM
454 }
455
d1e55a47
DM
456 /**
457 * Spins around an element until it exists
458 *
459 * @throws ExpectationException
460 * @param string $element
461 * @param string $selectortype
462 * @return void
463 */
464 protected function ensure_element_exists($element, $selectortype) {
465
466 // Getting the behat selector & locator.
467 list($selector, $locator) = $this->transform_selector($selectortype, $element);
468
469 // Exception if it timesout and the element is still there.
c1faf86b 470 $msg = 'The "' . $element . '" element does not exist and should exist';
d1e55a47
DM
471 $exception = new ExpectationException($msg, $this->getSession());
472
c1faf86b 473 // It will stop spinning once the find() method returns true.
d1e55a47
DM
474 $this->spin(
475 function($context, $args) {
476 // We don't use behat_base::find as it is already spinning.
477 if ($context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
478 return true;
479 }
c1faf86b 480 return false;
d1e55a47
DM
481 },
482 array('selector' => $selector, 'locator' => $locator),
483 self::EXTENDED_TIMEOUT,
484 $exception,
485 true
486 );
487
488 }
489
490 /**
491 * Spins until the element does not exist
492 *
493 * @throws ExpectationException
494 * @param string $element
495 * @param string $selectortype
496 * @return void
497 */
498 protected function ensure_element_does_not_exist($element, $selectortype) {
499
500 // Getting the behat selector & locator.
501 list($selector, $locator) = $this->transform_selector($selectortype, $element);
502
503 // Exception if it timesout and the element is still there.
c1faf86b 504 $msg = 'The "' . $element . '" element exists and should not exist';
d1e55a47
DM
505 $exception = new ExpectationException($msg, $this->getSession());
506
c1faf86b 507 // It will stop spinning once the find() method returns false.
d1e55a47
DM
508 $this->spin(
509 function($context, $args) {
c1faf86b 510 // We don't use behat_base::find() as we are already spinning.
d1e55a47
DM
511 if (!$context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
512 return true;
513 }
c1faf86b 514 return false;
d1e55a47
DM
515 },
516 array('selector' => $selector, 'locator' => $locator),
517 self::EXTENDED_TIMEOUT,
518 $exception,
519 true
520 );
521 }
522
523 /**
524 * Ensures that the provided node is visible and we can interact with it.
525 *
526 * @throws ExpectationException
527 * @param NodeElement $node
528 * @return void Throws an exception if it times out without the element being visible
529 */
530 protected function ensure_node_is_visible($node) {
531
532 if (!$this->running_javascript()) {
533 return;
534 }
535
536 // Exception if it timesout and the element is still there.
c1faf86b 537 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
d1e55a47
DM
538 $exception = new ExpectationException($msg, $this->getSession());
539
c1faf86b 540 // It will stop spinning once the isVisible() method returns true.
d1e55a47
DM
541 $this->spin(
542 function($context, $args) {
543 if ($args->isVisible()) {
544 return true;
545 }
c1faf86b 546 return false;
d1e55a47
DM
547 },
548 $node,
549 self::EXTENDED_TIMEOUT,
550 $exception,
551 true
552 );
553 }
554
555 /**
556 * Ensures that the provided element is visible and we can interact with it.
557 *
558 * Returns the node in case other actions are interested in using it.
559 *
560 * @throws ExpectationException
561 * @param string $element
562 * @param string $selectortype
563 * @return NodeElement Throws an exception if it times out without being visible
564 */
565 protected function ensure_element_is_visible($element, $selectortype) {
566
567 if (!$this->running_javascript()) {
568 return;
569 }
570
571 $node = $this->get_selected_node($selectortype, $element);
572 $this->ensure_node_is_visible($node);
573
574 return $node;
575 }
576
577 /**
578 * Ensures that all the page's editors are loaded.
579 *
580 * This method is expensive as it waits for .mceEditor CSS
581 * so use with caution and only where there will be editors.
582 *
583 * @throws ElementNotFoundException
c1faf86b 584 * @throws ExpectationException
d1e55a47
DM
585 * @return void
586 */
587 protected function ensure_editors_are_loaded() {
588
589 if (!$this->running_javascript()) {
590 return;
591 }
592
d1e55a47 593 // If there are no editors we don't need to wait.
d1e55a47 594 try {
74c78e74 595 $this->find('css', '.mceEditor', false, false, self::REDUCED_TIMEOUT);
d1e55a47
DM
596 } catch (ElementNotFoundException $e) {
597 return;
598 }
599
c1faf86b
DM
600 // Exception if it timesout and the element is not appearing.
601 $msg = 'The editors are not completely loaded';
602 $exception = new ExpectationException($msg, $this->getSession());
d1e55a47 603
c1faf86b
DM
604 // Here we know that there are .mceEditor editors in the page and we will
605 // probably need to interact with them, if we use tinyMCE JS var before
606 // it exists it will throw an exception and we want to catch it until all
607 // the page's editors are ready to interact with them.
608 $this->spin(
609 function($context) {
d1e55a47
DM
610
611 // It may return 0 if tinyMCE is loaded but not the instances, so we just loop again.
c1faf86b
DM
612 $neditors = $context->getSession()->evaluateScript('return tinyMCE.editors.length;');
613 if ($neditors == 0) {
614 return false;
d1e55a47
DM
615 }
616
c1faf86b
DM
617 // It may be there but not ready.
618 $iframeready = $context->getSession()->evaluateScript('
619 var readyeditors = new Array;
620 for (editorid in tinyMCE.editors) {
621 if (tinyMCE.editors[editorid].getDoc().readyState === "complete") {
622 readyeditors[editorid] = editorid;
623 }
624 }
625 if (tinyMCE.editors.length === readyeditors.length) {
626 return "complete";
627 }
628 return "";
629 ');
d1e55a47 630
c1faf86b
DM
631 // Now we know that the editors are there.
632 if ($iframeready) {
633 return true;
634 }
d1e55a47 635
c1faf86b
DM
636 // Loop again if it is not ready.
637 return false;
638 },
639 false,
640 self::EXTENDED_TIMEOUT,
641 $exception,
642 true
643 );
d1e55a47 644 }
c1faf86b 645
d4322e38 646}