See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
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 * A trait containing functionality used by the behat base context, and form fields. 19 * 20 * @package core 21 * @category test 22 * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 use Behat\Mink\Element\NodeElement; 27 use Behat\Mink\Element\Element; 28 use Behat\Mink\Exception\DriverException; 29 use Behat\Mink\Exception\ExpectationException; 30 use Behat\Mink\Exception\ElementNotFoundException; 31 use Behat\Mink\Exception\NoSuchWindowException; 32 use Behat\Mink\Session; 33 use Behat\Testwork\Hook\Scope\HookScope; 34 use Facebook\WebDriver\Exception\ScriptTimeoutException; 35 use Facebook\WebDriver\WebDriverBy; 36 use Facebook\WebDriver\WebDriverElement; 37 38 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. 39 40 require_once (__DIR__ . '/component_named_replacement.php'); 41 require_once (__DIR__ . '/component_named_selector.php'); 42 43 // Alias the Facebook\WebDriver\WebDriverKeys class to behat_keys for better b/c with the older Instaclick driver. 44 class_alias('Facebook\WebDriver\WebDriverKeys', 'behat_keys'); 45 46 /** 47 * A trait containing functionality used by the behat base context, and form fields. 48 * 49 * This trait should be used by the behat_base context, and behat form fields, and it should be paired with the 50 * behat_session_interface interface. 51 * 52 * It should not be necessary to use this trait, and the behat_session_interface interface in normal circumstances. 53 * 54 * @package core 55 * @category test 56 * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> 57 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 58 */ 59 trait behat_session_trait { 60 61 /** 62 * Locates url, based on provided path. 63 * Override to provide custom routing mechanism. 64 * 65 * @see Behat\MinkExtension\Context\MinkContext 66 * @param string $path 67 * @return string 68 */ 69 protected function locate_path($path) { 70 $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/'; 71 return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path; 72 } 73 74 /** 75 * Returns the first matching element. 76 * 77 * @link http://mink.behat.org/#traverse-the-page-selectors 78 * @param string $selector The selector type (css, xpath, named...) 79 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... 80 * @param Exception $exception Otherwise we throw exception with generic info 81 * @param NodeElement $node Spins around certain DOM node instead of the whole page 82 * @param int $timeout Forces a specific time out (in seconds). 83 * @return NodeElement 84 */ 85 protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) { 86 if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) { 87 // Support a NodeElement being passed in for use in step chaining. 88 return $locator; 89 } 90 91 // Returns the first match. 92 $items = $this->find_all($selector, $locator, $exception, $node, $timeout); 93 return count($items) ? reset($items) : null; 94 } 95 96 /** 97 * Returns all matching elements. 98 * 99 * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method. 100 * 101 * @link http://mink.behat.org/#traverse-the-page-selectors 102 * @param string $selector The selector type (css, xpath, named...) 103 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... 104 * @param Exception $exception Otherwise we throw expcetion with generic info 105 * @param NodeElement $container Restrict the search to just children of the specified container 106 * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied. 107 * @return array NodeElements list 108 */ 109 protected function find_all($selector, $locator, $exception = false, $container = false, $timeout = false) { 110 // Throw exception, so dev knows it is not supported. 111 if ($selector === 'named') { 112 $exception = 'Using the "named" selector is deprecated as of 3.1. ' 113 .' Use the "named_partial" or use the "named_exact" selector instead.'; 114 throw new ExpectationException($exception, $this->getSession()); 115 } 116 117 // Generic info. 118 if (!$exception) { 119 // With named selectors we can be more specific. 120 if (($selector == 'named_exact') || ($selector == 'named_partial')) { 121 $exceptiontype = $locator[0]; 122 $exceptionlocator = $locator[1]; 123 124 // If we are in a @javascript session all contents would be displayed as HTML characters. 125 if ($this->running_javascript()) { 126 $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES); 127 } 128 129 } else { 130 $exceptiontype = $selector; 131 $exceptionlocator = $locator; 132 } 133 134 $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator); 135 } 136 137 // How much we will be waiting for the element to appear. 138 if ($timeout === false) { 139 $timeout = self::get_timeout(); 140 $microsleep = false; 141 } else { 142 // Spinning each 0.1 seconds if the timeout was forced as we understand 143 // that is a special case and is good to refine the performance as much 144 // as possible. 145 $microsleep = true; 146 } 147 148 // Normalise the values in order to perform the search. 149 [ 150 'selector' => $selector, 151 'locator' => $locator, 152 'container' => $container, 153 ] = $this->normalise_selector($selector, $locator, $container ?: $this->getSession()->getPage()); 154 155 // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception. 156 return $this->spin( 157 function() use ($selector, $locator, $container) { 158 return $container->findAll($selector, $locator); 159 }, [], $timeout, $exception, $microsleep 160 ); 161 } 162 163 /** 164 * Normalise the locator and selector. 165 * 166 * @param string $selector The type of thing to search 167 * @param mixed $locator The locator value. Can be an array, but is more likely a string. 168 * @param Element $container An optional container to search within 169 * @return array The selector, locator, and container to search within 170 */ 171 public function normalise_selector(string $selector, $locator, Element $container): array { 172 // Check for specific transformations for this selector type. 173 $transformfunction = "transform_find_for_{$selector}"; 174 if (method_exists('behat_selectors', $transformfunction)) { 175 // A selector-specific transformation exists. 176 // Perform initial transformation of the selector within the current container. 177 [ 178 'selector' => $selector, 179 'locator' => $locator, 180 'container' => $container, 181 ] = behat_selectors::{$transformfunction}($this, $locator, $container); 182 } 183 184 // Normalise the css and xpath selector types. 185 if ('css_element' === $selector) { 186 $selector = 'css'; 187 } else if ('xpath_element' === $selector) { 188 $selector = 'xpath'; 189 } 190 191 // Convert to a named selector where the selector type is not a known selector. 192 $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector); 193 $converttonamed = $converttonamed && 'xpath' !== $selector; 194 if ($converttonamed) { 195 if (behat_partial_named_selector::is_deprecated_selector($selector)) { 196 if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) { 197 error_log("The '{$selector}' selector has been replaced with {$replacement}"); 198 $selector = $replacement; 199 } 200 } else if (behat_exact_named_selector::is_deprecated_selector($selector)) { 201 if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) { 202 error_log("The '{$selector}' selector has been replaced with {$replacement}"); 203 $selector = $replacement; 204 } 205 } 206 207 $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors(); 208 $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors(); 209 if (isset($allowedpartialselectors[$selector])) { 210 $locator = behat_selectors::normalise_named_selector($allowedpartialselectors[$selector], $locator); 211 $selector = 'named_partial'; 212 } else if (isset($allowedexactselectors[$selector])) { 213 $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator); 214 $selector = 'named_exact'; 215 } else { 216 throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver()); 217 } 218 } 219 220 return [ 221 'selector' => $selector, 222 'locator' => $locator, 223 'container' => $container, 224 ]; 225 } 226 227 /** 228 * Get a description of the selector and locator to use in an exception message. 229 * 230 * @param string $selector The type of locator 231 * @param mixed $locator The locator text 232 * @return string 233 */ 234 protected function get_selector_description(string $selector, $locator): string { 235 if ($selector === 'NodeElement') { 236 $description = $locator->getText(); 237 return "'{$description}' {$selector}"; 238 } 239 240 return "'{$locator}' {$selector}"; 241 } 242 243 /** 244 * Send key presses straight to the currently active element. 245 * 246 * The `$keys` array contains a list of key values to send to the session as defined in the WebDriver and JsonWire 247 * specifications: 248 * - JsonWire: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#sessionsessionidkeys 249 * - W3C WebDriver: https://www.w3.org/TR/webdriver/#keyboard-actions 250 * 251 * This may be a combination of typable characters, modifier keys, and other supported keypoints. 252 * 253 * The NULL_KEY should be used to release modifier keys. If the NULL_KEY is not used then modifier keys will remain 254 * in the pressed state. 255 * 256 * Example usage: 257 * 258 * behat_base::type_keys($this->getSession(), [behat_keys::SHIFT, behat_keys::TAB, behat_keys::NULL_KEY]); 259 * behat_base::type_keys($this->getSession(), [behat_keys::ENTER, behat_keys::NULL_KEY]); 260 * behat_base::type_keys($this->getSession(), [behat_keys::ESCAPE, behat_keys::NULL_KEY]); 261 * 262 * It can also be used to send text input, for example: 263 * 264 * behat_base::type_keys( 265 * $this->getSession(), 266 * ['D', 'o', ' ', 'y', 'o', 'u', ' ', 'p', 'l', 'a' 'y', ' ', 'G', 'o', '?', behat_base::NULL_KEY] 267 * ); 268 * 269 * 270 * Please note: This function does not use the element/sendKeys variants but sends keys straight to the browser. 271 * 272 * @param Session $session 273 * @param string[] $keys 274 */ 275 public static function type_keys(Session $session, array $keys): void { 276 $session->getDriver()->getWebDriver()->getKeyboard()->sendKeys($keys); 277 } 278 279 /** 280 * Finds DOM nodes in the page using named selectors. 281 * 282 * The point of using this method instead of Mink ones is the spin 283 * method of behat_base::find() that looks for the element until it 284 * is available or it timeouts, this avoids the false failures received 285 * when selenium tries to execute commands on elements that are not 286 * ready to be used. 287 * 288 * All steps that requires elements to be available before interact with 289 * them should use one of the find* methods. 290 * 291 * The methods calls requires a {'find_' . $elementtype}($locator) 292 * format, like find_link($locator), find_select($locator), 293 * find_button($locator)... 294 * 295 * @link http://mink.behat.org/#named-selectors 296 * @throws coding_exception 297 * @param string $name The name of the called method 298 * @param mixed $arguments 299 * @return NodeElement 300 */ 301 public function __call($name, $arguments) { 302 if (substr($name, 0, 5) === 'find_') { 303 return call_user_func_array([$this, 'find'], array_merge( 304 [substr($name, 5)], 305 $arguments 306 )); 307 } 308 309 throw new coding_exception("The '{$name}' method does not exist"); 310 } 311 312 /** 313 * Escapes the double quote character. 314 * 315 * Double quote is the argument delimiter, it can be escaped 316 * with a backslash, but we auto-remove this backslashes 317 * before the step execution, this method is useful when using 318 * arguments as arguments for other steps. 319 * 320 * @param string $string 321 * @return string 322 */ 323 public function escape($string) { 324 return str_replace('"', '\"', $string); 325 } 326 327 /** 328 * Executes the passed closure until returns true or time outs. 329 * 330 * In most cases the document.readyState === 'complete' will be enough, but sometimes JS 331 * requires more time to be completely loaded or an element to be visible or whatever is required to 332 * perform some action on an element; this method receives a closure which should contain the 333 * required statements to ensure the step definition actions and assertions have all their needs 334 * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the 335 * closure to the caller. 336 * 337 * The closures requirements to work well with this spin method are: 338 * - Must return false, null or '' if something goes wrong 339 * - Must return something != false if finishes as expected, this will be the (mixed) value 340 * returned by spin() 341 * 342 * The arguments of the closure are mixed, use $args depending on your needs. 343 * 344 * You can provide an exception to give more accurate feedback to tests writers, otherwise the 345 * closure exception will be used, but you must provide an exception if the closure does not throw 346 * an exception. 347 * 348 * @throws Exception If it timeouts without receiving something != false from the closure 349 * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method) 350 * @param mixed $args Arguments to pass to the closure 351 * @param int $timeout Timeout in seconds 352 * @param Exception $exception The exception to throw in case it time outs. 353 * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds. 354 * @return mixed The value returned by the closure 355 */ 356 protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) { 357 358 // Using default timeout which is pretty high. 359 if ($timeout === false) { 360 $timeout = self::get_timeout(); 361 } 362 363 $start = microtime(true); 364 $end = $start + $timeout; 365 366 do { 367 // We catch the exception thrown by the step definition to execute it again. 368 try { 369 // We don't check with !== because most of the time closures will return 370 // direct Behat methods returns and we are not sure it will be always (bool)false 371 // if it just runs the behat method without returning anything $return == null. 372 if ($return = call_user_func($lambda, $this, $args)) { 373 return $return; 374 } 375 } catch (Exception $e) { 376 // We would use the first closure exception if no exception has been provided. 377 if (!$exception) { 378 $exception = $e; 379 } 380 } 381 382 if (!$this->running_javascript()) { 383 break; 384 } 385 386 usleep(100000); 387 388 } while (microtime(true) < $end); 389 390 // Using coding_exception as is a development issue if no exception has been provided. 391 if (!$exception) { 392 $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception'); 393 } 394 395 // Throwing exception to the user. 396 throw $exception; 397 } 398 399 /** 400 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. 401 * 402 * Use behat_base::get_text_selector_node() for text-based selectors. 403 * 404 * @throws ElementNotFoundException Thrown by behat_base::find 405 * @param string $selectortype 406 * @param string $element 407 * @return NodeElement 408 */ 409 protected function get_selected_node($selectortype, $element) { 410 return $this->find($selectortype, $element); 411 } 412 413 /** 414 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. 415 * 416 * @throws ElementNotFoundException Thrown by behat_base::find 417 * @param string $selectortype 418 * @param string $element 419 * @return NodeElement 420 */ 421 protected function get_text_selector_node($selectortype, $element) { 422 // Getting Mink selector and locator. 423 list($selector, $locator) = $this->transform_text_selector($selectortype, $element); 424 425 // Returns the NodeElement. 426 return $this->find($selector, $locator); 427 } 428 429 /** 430 * Gets the requested element inside the specified container. 431 * 432 * @throws ElementNotFoundException Thrown by behat_base::find 433 * @param mixed $selectortype The element selector type. 434 * @param mixed $element The element locator. 435 * @param mixed $containerselectortype The container selector type. 436 * @param mixed $containerelement The container locator. 437 * @return NodeElement 438 */ 439 protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) { 440 if ($containerselectortype === 'NodeElement' && is_a($containerelement, NodeElement::class)) { 441 // Support a NodeElement being passed in for use in step chaining. 442 $containernode = $containerelement; 443 } else { 444 // Gets the container, it will always be text based. 445 $containernode = $this->get_text_selector_node($containerselectortype, $containerelement); 446 } 447 448 $locatorexceptionmsg = $element . '" in the "' . $this->get_selector_description($containerselectortype, $containerelement); 449 $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg); 450 451 return $this->find($selectortype, $element, $exception, $containernode); 452 } 453 454 /** 455 * Transforms from step definition's argument style to Mink format. 456 * 457 * Mink has 3 different selectors css, xpath and named, where named 458 * selectors includes link, button, field... to simplify and group multiple 459 * steps in one we use the same interface, considering all link, buttons... 460 * at the same level as css selectors and xpath; this method makes the 461 * conversion from the arguments received by the steps to the selectors and locators 462 * required to interact with Mink. 463 * 464 * @throws ExpectationException 465 * @param string $selectortype It can be css, xpath or any of the named selectors. 466 * @param string $element The locator (or string) we are looking for. 467 * @return array Contains the selector and the locator expected by Mink. 468 */ 469 protected function transform_selector($selectortype, $element) { 470 // Here we don't know if an allowed text selector is being used. 471 $selectors = behat_selectors::get_allowed_selectors(); 472 if (!isset($selectors[$selectortype])) { 473 throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession()); 474 } 475 476 [ 477 'selector' => $selector, 478 'locator' => $locator, 479 ] = $this->normalise_selector($selectortype, $element, $this->getSession()->getPage()); 480 481 return [$selector, $locator]; 482 } 483 484 /** 485 * Transforms from step definition's argument style to Mink format. 486 * 487 * Delegates all the process to behat_base::transform_selector() checking 488 * the provided $selectortype. 489 * 490 * @throws ExpectationException 491 * @param string $selectortype It can be css, xpath or any of the named selectors. 492 * @param string $element The locator (or string) we are looking for. 493 * @return array Contains the selector and the locator expected by Mink. 494 */ 495 protected function transform_text_selector($selectortype, $element) { 496 497 $selectors = behat_selectors::get_allowed_text_selectors(); 498 if (empty($selectors[$selectortype])) { 499 throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession()); 500 } 501 502 return $this->transform_selector($selectortype, $element); 503 } 504 505 /** 506 * Whether Javascript is available in the current Session. 507 * 508 * @return boolean 509 */ 510 protected function running_javascript() { 511 return self::running_javascript_in_session($this->getSession()); 512 } 513 514 /** 515 * Require that javascript be available in the current Session. 516 * 517 * @param null|string $message An additional information message to show when JS is not available 518 * @throws DriverException 519 */ 520 protected function require_javascript(?string $message = null) { 521 return self::require_javascript_in_session($this->getSession(), $message); 522 } 523 524 /** 525 * Whether Javascript is available in the specified Session. 526 * 527 * @param Session $session 528 * @return boolean 529 */ 530 protected static function running_javascript_in_session(Session $session): bool { 531 return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver'; 532 } 533 534 /** 535 * Require that javascript be available for the specified Session. 536 * 537 * @param Session $session 538 * @param null|string $message An additional information message to show when JS is not available 539 * @throws DriverException 540 */ 541 protected static function require_javascript_in_session(Session $session, ?string $message = null): void { 542 if (self::running_javascript_in_session($session)) { 543 return; 544 } 545 546 $error = "Javascript is required for this step."; 547 if ($message) { 548 $error = "{$error} {$message}"; 549 } 550 throw new DriverException($error); 551 } 552 553 /** 554 * Checks if the current page is part of the mobile app. 555 * 556 * @return bool True if it's in the app 557 */ 558 protected function is_in_app() : bool { 559 // Cannot be in the app if there's no @app tag on scenario. 560 if (!$this->has_tag('app')) { 561 return false; 562 } 563 564 // Check on page to see if it's an app page. Safest way is to look for added JavaScript. 565 return $this->evaluate_script('return typeof window.behat') === 'object'; 566 } 567 568 /** 569 * Spins around an element until it exists 570 * 571 * @throws ExpectationException 572 * @param string $locator 573 * @param string $selectortype 574 * @return void 575 */ 576 protected function ensure_element_exists($locator, $selectortype) { 577 // Exception if it timesout and the element is still there. 578 $msg = "The '{$locator}' element does not exist and should"; 579 $exception = new ExpectationException($msg, $this->getSession()); 580 581 // Normalise the values in order to perform the search. 582 [ 583 'selector' => $selector, 584 'locator' => $locator, 585 'container' => $container, 586 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 587 588 // It will stop spinning once the find() method returns true. 589 $this->spin( 590 function() use ($selector, $locator, $container) { 591 if ($container->find($selector, $locator)) { 592 return true; 593 } 594 return false; 595 }, 596 [], 597 self::get_extended_timeout(), 598 $exception, 599 true 600 ); 601 } 602 603 /** 604 * Spins until the element does not exist 605 * 606 * @throws ExpectationException 607 * @param string $locator 608 * @param string $selectortype 609 * @return void 610 */ 611 protected function ensure_element_does_not_exist($locator, $selectortype) { 612 // Exception if it timesout and the element is still there. 613 $msg = "The '{$locator}' element exists and should not exist"; 614 $exception = new ExpectationException($msg, $this->getSession()); 615 616 // Normalise the values in order to perform the search. 617 [ 618 'selector' => $selector, 619 'locator' => $locator, 620 'container' => $container, 621 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 622 623 // It will stop spinning once the find() method returns false. 624 $this->spin( 625 function() use ($selector, $locator, $container) { 626 if ($container->find($selector, $locator)) { 627 return false; 628 } 629 return true; 630 }, 631 // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin(). 632 // We cannot nest spins because the outer spin times out before the inner spin completes. 633 [], 634 self::get_extended_timeout(), 635 $exception, 636 true 637 ); 638 } 639 640 /** 641 * Ensures that the provided node is visible and we can interact with it. 642 * 643 * @throws ExpectationException 644 * @param NodeElement $node 645 * @return void Throws an exception if it times out without the element being visible 646 */ 647 protected function ensure_node_is_visible($node) { 648 649 if (!$this->running_javascript()) { 650 return; 651 } 652 653 // Exception if it timesout and the element is still there. 654 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 655 $exception = new ExpectationException($msg, $this->getSession()); 656 657 // It will stop spinning once the isVisible() method returns true. 658 $this->spin( 659 function($context, $args) { 660 if ($args->isVisible()) { 661 return true; 662 } 663 return false; 664 }, 665 $node, 666 self::get_extended_timeout(), 667 $exception, 668 true 669 ); 670 } 671 672 /** 673 * Ensures that the provided node has a attribute value set. This step can be used to check if specific 674 * JS has finished modifying the node. 675 * 676 * @throws ExpectationException 677 * @param NodeElement $node 678 * @param string $attribute attribute name 679 * @param string $attributevalue attribute value to check. 680 * @return void Throws an exception if it times out without the element being visible 681 */ 682 protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) { 683 684 if (!$this->running_javascript()) { 685 return; 686 } 687 688 // Exception if it timesout and the element is still there. 689 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 690 $exception = new ExpectationException($msg, $this->getSession()); 691 692 // It will stop spinning once the $args[1]) == $args[2], and method returns true. 693 $this->spin( 694 function($context, $args) { 695 if ($args[0]->getAttribute($args[1]) == $args[2]) { 696 return true; 697 } 698 return false; 699 }, 700 array($node, $attribute, $attributevalue), 701 self::get_extended_timeout(), 702 $exception, 703 true 704 ); 705 } 706 707 /** 708 * Ensures that the provided element is visible and we can interact with it. 709 * 710 * Returns the node in case other actions are interested in using it. 711 * 712 * @throws ExpectationException 713 * @param string $element 714 * @param string $selectortype 715 * @return NodeElement Throws an exception if it times out without being visible 716 */ 717 protected function ensure_element_is_visible($element, $selectortype) { 718 719 if (!$this->running_javascript()) { 720 return; 721 } 722 723 $node = $this->get_selected_node($selectortype, $element); 724 $this->ensure_node_is_visible($node); 725 726 return $node; 727 } 728 729 /** 730 * Ensures that all the page's editors are loaded. 731 * 732 * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more. 733 * @throws ElementNotFoundException 734 * @throws ExpectationException 735 * @return void 736 */ 737 protected function ensure_editors_are_loaded() { 738 global $CFG; 739 740 if (empty($CFG->behat_usedeprecated)) { 741 debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.'); 742 } 743 return; 744 } 745 746 /** 747 * Checks if the current scenario, or its feature, has a specified tag. 748 * 749 * @param string $tag Tag to check 750 * @return bool True if the tag exists in scenario or feature 751 */ 752 public function has_tag(string $tag) : bool { 753 return array_key_exists($tag, behat_hooks::get_tags_for_scenario()); 754 } 755 756 /** 757 * Change browser window size. 758 * - mobile: 425x750 759 * - tablet: 768x1024 760 * - small: 1024x768 761 * - medium: 1366x768 762 * - large: 2560x1600 763 * 764 * @param string $windowsize size of window. 765 * @param bool $viewport If true, changes viewport rather than window size 766 * @throws ExpectationException 767 */ 768 protected function resize_window($windowsize, $viewport = false) { 769 global $CFG; 770 771 // Non JS don't support resize window. 772 if (!$this->running_javascript()) { 773 return; 774 } 775 776 switch ($windowsize) { 777 case "mobile": 778 $width = 425; 779 $height = 750; 780 break; 781 case "tablet": 782 $width = 768; 783 $height = 1024; 784 break; 785 case "small": 786 $width = 1024; 787 $height = 768; 788 break; 789 case "medium": 790 $width = 1366; 791 $height = 768; 792 break; 793 case "large": 794 $width = 2560; 795 $height = 1600; 796 break; 797 default: 798 preg_match('/^(\d+x\d+)$/', $windowsize, $matches); 799 if (empty($matches) || (count($matches) != 2)) { 800 throw new ExpectationException("Invalid screen size, can't resize", $this->getSession()); 801 } 802 $size = explode('x', $windowsize); 803 $width = (int) $size[0]; 804 $height = (int) $size[1]; 805 } 806 807 if (isset($CFG->behat_window_size_modifier) && is_numeric($CFG->behat_window_size_modifier)) { 808 $width *= $CFG->behat_window_size_modifier; 809 $height *= $CFG->behat_window_size_modifier; 810 } 811 812 if ($viewport) { 813 // When setting viewport size, we set it so that the document width will be exactly 814 // as specified, assuming that there is a vertical scrollbar. (In cases where there is 815 // no scrollbar it will be slightly wider. We presume this is rare and predictable.) 816 // The window inner height will be as specified, which means the available viewport will 817 // actually be smaller if there is a horizontal scrollbar. We assume that horizontal 818 // scrollbars are rare so this doesn't matter. 819 $js = <<<EOF 820 return (function() { 821 var before = document.body.style.overflowY; 822 document.body.style.overflowY = "scroll"; 823 var result = {}; 824 result.x = window.outerWidth - document.body.offsetWidth; 825 result.y = window.outerHeight - window.innerHeight; 826 document.body.style.overflowY = before; 827 return result; 828 })(); 829 EOF; 830 $offset = $this->evaluate_script($js); 831 $width += $offset['x']; 832 $height += $offset['y']; 833 } 834 835 $this->getSession()->getDriver()->resizeWindow($width, $height); 836 } 837 838 /** 839 * Waits for all the JS to be loaded. 840 * 841 * @return bool Whether any JS is still pending completion. 842 */ 843 public function wait_for_pending_js() { 844 return static::wait_for_pending_js_in_session($this->getSession()); 845 } 846 847 /** 848 * Waits for all the JS to be loaded. 849 * 850 * @param Session $session The Mink Session where JS can be run 851 * @return bool Whether any JS is still pending completion. 852 */ 853 public static function wait_for_pending_js_in_session(Session $session) { 854 if (!self::running_javascript_in_session($session)) { 855 // JS is not available therefore there is nothing to wait for. 856 return false; 857 } 858 859 // We don't use behat_base::spin() here as we don't want to end up with an exception 860 // if the page & JSs don't finish loading properly. 861 for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) { 862 $pending = ''; 863 try { 864 $jscode = trim(preg_replace('/\s+/', ' ', ' 865 return (function() { 866 if (document.readyState !== "complete") { 867 return "incomplete"; 868 } 869 870 if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") { 871 return ""; 872 } 873 874 return M.util.pending_js.join(":"); 875 })()')); 876 $pending = self::evaluate_script_in_session($session, $jscode); 877 } catch (NoSuchWindowException $nsw) { 878 // We catch an exception here, in case we just closed the window we were interacting with. 879 // No javascript is running if there is no window right? 880 $pending = ''; 881 } catch (UnknownError $e) { 882 // M is not defined when the window or the frame don't exist anymore. 883 if (strstr($e->getMessage(), 'M is not defined') != false) { 884 $pending = ''; 885 } 886 } 887 888 // If there are no pending JS we stop waiting. 889 if ($pending === '') { 890 return true; 891 } 892 893 // 0.1 seconds. 894 usleep(100000); 895 } 896 897 // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions(). 898 // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds 899 // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the 900 // number of JS pending code and JS completed code will not match and we will reach this point. 901 throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . 902 self::get_extended_timeout() . 903 ' seconds. There is a Javascript error or the code is extremely slow (' . $pending . 904 '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.'); 905 } 906 907 /** 908 * Internal step definition to find exceptions, debugging() messages and PHP debug messages. 909 * 910 * Part of behat_hooks class as is part of the testing framework, is auto-executed 911 * after each step so no features will splicitly use it. 912 * 913 * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception. 914 * @see Moodle\BehatExtension\Tester\MoodleStepTester 915 */ 916 public function look_for_exceptions() { 917 // Wrap in try in case we were interacting with a closed window. 918 try { 919 920 // Exceptions. 921 $exceptionsxpath = "//div[@data-rel='fatalerror']"; 922 // Debugging messages. 923 $debuggingxpath = "//div[@data-rel='debugging']"; 924 // PHP debug messages. 925 $phperrorxpath = "//div[@data-rel='phpdebugmessage']"; 926 // Any other backtrace. 927 $othersxpath = "(//*[contains(., ': call to ')])[1]"; 928 929 $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath); 930 $joinedxpath = implode(' | ', $xpaths); 931 932 // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check 933 // is faster than to send the 4 xpath queries for each step. 934 if (!$this->getSession()->getDriver()->find($joinedxpath)) { 935 // Check if we have recorded any errors in driver process. 936 $phperrors = behat_get_shutdown_process_errors(); 937 if (!empty($phperrors)) { 938 foreach ($phperrors as $error) { 939 $errnostring = behat_get_error_string($error['type']); 940 $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line']; 941 } 942 $msg = "PHP errors found:\n" . implode("\n", $msgs); 943 throw new \Exception(htmlentities($msg, ENT_COMPAT)); 944 } 945 946 return; 947 } 948 949 // Exceptions. 950 if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) { 951 952 // Getting the debugging info and the backtrace. 953 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error'); 954 // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class. 955 if (empty($errorinfoboxes)) { 956 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger'); 957 } 958 // If errorinfoboxes is empty, try find notifytiny (original) class. 959 if (empty($errorinfoboxes)) { 960 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny'); 961 } 962 963 // If errorinfoboxes is empty, try find ajax/JS exception in dialogue. 964 if (empty($errorinfoboxes)) { 965 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message'); 966 967 // If ajax/JS exception. 968 if ($errorinfoboxes) { 969 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()); 970 } 971 972 } else { 973 $errorinfo = implode("\n", [ 974 $this->get_debug_text($errorinfoboxes[0]->getHtml()), 975 $this->get_debug_text($errorinfoboxes[1]->getHtml()), 976 html_to_text($errorinfoboxes[2]->find('css', 'ul')->getHtml()), 977 ]); 978 } 979 980 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo; 981 throw new \Exception(html_entity_decode($msg, ENT_COMPAT)); 982 } 983 984 // Debugging messages. 985 if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) { 986 $msgs = array(); 987 foreach ($debuggingmessages as $debuggingmessage) { 988 $msgs[] = $this->get_debug_text($debuggingmessage->getHtml()); 989 } 990 $msg = "debugging() message/s found:\n" . implode("\n", $msgs); 991 throw new \Exception(html_entity_decode($msg, ENT_COMPAT)); 992 } 993 994 // PHP debug messages. 995 if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) { 996 997 $msgs = array(); 998 foreach ($phpmessages as $phpmessage) { 999 $msgs[] = $this->get_debug_text($phpmessage->getHtml()); 1000 } 1001 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs); 1002 throw new \Exception(html_entity_decode($msg, ENT_COMPAT)); 1003 } 1004 1005 // Any other backtrace. 1006 // First looking through xpath as it is faster than get and parse the whole page contents, 1007 // we get the contents and look for matches once we found something to suspect that there is a backtrace. 1008 if ($this->getSession()->getDriver()->find($othersxpath)) { 1009 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/'; 1010 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) { 1011 $msgs = array(); 1012 foreach ($backtraces[0] as $backtrace) { 1013 $msgs[] = $backtrace . '()'; 1014 } 1015 $msg = "Other backtraces found:\n" . implode("\n", $msgs); 1016 throw new \Exception(htmlentities($msg, ENT_COMPAT)); 1017 } 1018 } 1019 1020 } catch (NoSuchWindowException $e) { 1021 // If we were interacting with a popup window it will not exists after closing it. 1022 } catch (DriverException $e) { 1023 // Same reason as above. 1024 } 1025 } 1026 1027 /** 1028 * Converts HTML tags to line breaks to display the info in CLI 1029 * 1030 * @param string $html 1031 * @return string 1032 */ 1033 protected function get_debug_text($html) { 1034 1035 // Replacing HTML tags for new lines and keeping only the text. 1036 $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html); 1037 return preg_replace("/(\n)+/s", "\n", $notags); 1038 } 1039 1040 /** 1041 * Helper function to execute api in a given context. 1042 * 1043 * @param string $contextapi context in which api is defined. 1044 * @param array $params list of params to pass. 1045 * @throws Exception 1046 */ 1047 protected function execute($contextapi, $params = array()) { 1048 if (!is_array($params)) { 1049 $params = array($params); 1050 } 1051 1052 // Get required context and execute the api. 1053 $contextapi = explode("::", $contextapi); 1054 $context = behat_context_helper::get($contextapi[0]); 1055 call_user_func_array(array($context, $contextapi[1]), $params); 1056 1057 // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results. 1058 // Don't make them optional for performance reasons. 1059 1060 // Wait for pending js. 1061 $this->wait_for_pending_js(); 1062 1063 // Look for exceptions. 1064 $this->look_for_exceptions(); 1065 } 1066 1067 /** 1068 * Execute a function in a specific behat context. 1069 * 1070 * For example, to call the 'set_editor_value' function for all editors, you would call: 1071 * 1072 * behat_base::execute_in_matching_contexts('editor', 'set_editor_value', ['Some value']); 1073 * 1074 * This would find all behat contexts whose class name starts with 'behat_editor_' and 1075 * call the 'set_editor_value' function on that context. 1076 * 1077 * @param string $prefix 1078 * @param string $method 1079 * @param array $params 1080 */ 1081 public static function execute_in_matching_contexts(string $prefix, string $method, array $params): void { 1082 $contexts = behat_context_helper::get_prefixed_contexts("behat_{$prefix}_"); 1083 foreach ($contexts as $context) { 1084 if (method_exists($context, $method) && is_callable([$context, $method])) { 1085 call_user_func_array([$context, $method], $params); 1086 } 1087 } 1088 } 1089 1090 /** 1091 * Get the actual user in the behat session (note $USER does not correspond to the behat session's user). 1092 * @return mixed 1093 * @throws coding_exception 1094 */ 1095 protected function get_session_user() { 1096 global $DB; 1097 1098 $sid = $this->getSession()->getCookie('MoodleSession'); 1099 if (empty($sid)) { 1100 throw new coding_exception('failed to get moodle session'); 1101 } 1102 $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]); 1103 if (empty($userid)) { 1104 throw new coding_exception('failed to get user from seession id '.$sid); 1105 } 1106 return $DB->get_record('user', ['id' => $userid]); 1107 } 1108 1109 /** 1110 * Set current $USER, reset access cache. 1111 * 1112 * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some 1113 * API's might rely on the logged user to take some action. 1114 * 1115 * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid 1116 */ 1117 public static function set_user($user = null) { 1118 global $DB; 1119 1120 if (is_object($user)) { 1121 $user = clone($user); 1122 } else if (!$user) { 1123 // Assign valid data to admin user (some generator-related code needs a valid user). 1124 $user = $DB->get_record('user', array('username' => 'admin')); 1125 } else { 1126 $user = $DB->get_record('user', array('id' => $user)); 1127 } 1128 unset($user->description); 1129 unset($user->access); 1130 unset($user->preference); 1131 1132 // Ensure session is empty, as it may contain caches and user specific info. 1133 \core\session\manager::init_empty_session(); 1134 1135 \core\session\manager::set_user($user); 1136 } 1137 1138 /** 1139 * Gets the internal moodle context id from the context reference. 1140 * 1141 * The context reference changes depending on the context 1142 * level, it can be the system, a user, a category, a course or 1143 * a module. 1144 * 1145 * @throws Exception 1146 * @param string $levelname The context level string introduced by the test writer 1147 * @param string $contextref The context reference introduced by the test writer 1148 * @return context 1149 */ 1150 public static function get_context(string $levelname, string $contextref): context { 1151 global $DB; 1152 1153 // Getting context levels and names (we will be using the English ones as it is the test site language). 1154 $contextlevels = context_helper::get_all_levels(); 1155 $contextnames = array(); 1156 foreach ($contextlevels as $level => $classname) { 1157 $contextnames[context_helper::get_level_name($level)] = $level; 1158 } 1159 1160 if (empty($contextnames[$levelname])) { 1161 throw new Exception('The specified "' . $levelname . '" context level does not exist'); 1162 } 1163 $contextlevel = $contextnames[$levelname]; 1164 1165 // Return it, we don't need to look for other internal ids. 1166 if ($contextlevel == CONTEXT_SYSTEM) { 1167 return context_system::instance(); 1168 } 1169 1170 switch ($contextlevel) { 1171 1172 case CONTEXT_USER: 1173 $instanceid = $DB->get_field('user', 'id', array('username' => $contextref)); 1174 break; 1175 1176 case CONTEXT_COURSECAT: 1177 $instanceid = $DB->get_field('course_categories', 'id', array('idnumber' => $contextref)); 1178 break; 1179 1180 case CONTEXT_COURSE: 1181 $instanceid = $DB->get_field('course', 'id', array('shortname' => $contextref)); 1182 break; 1183 1184 case CONTEXT_MODULE: 1185 $instanceid = $DB->get_field('course_modules', 'id', array('idnumber' => $contextref)); 1186 break; 1187 1188 default: 1189 break; 1190 } 1191 1192 $contextclass = $contextlevels[$contextlevel]; 1193 if (!$context = $contextclass::instance($instanceid, IGNORE_MISSING)) { 1194 throw new Exception('The specified "' . $contextref . '" context reference does not exist'); 1195 } 1196 1197 return $context; 1198 } 1199 1200 /** 1201 * Trigger click on node via javascript instead of actually clicking on it via pointer. 1202 * 1203 * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via 1204 * the pointer may accidentally cause a click on the wrong element. 1205 * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled. 1206 * @param NodeElement $node 1207 */ 1208 protected function js_trigger_click($node) { 1209 if (!$this->running_javascript()) { 1210 $node->click(); 1211 } 1212 $driver = $this->getSession()->getDriver(); 1213 if ($driver instanceof \Moodle\BehatExtension\Driver\WebDriver) { 1214 $this->execute_js_on_node($node, '{{ELEMENT}}.click();'); 1215 } else { 1216 $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked. 1217 $driver->click($node->getXpath()); 1218 } 1219 } 1220 1221 /** 1222 * Execute JS on the specified NodeElement. 1223 * 1224 * @param NodeElement $node 1225 * @param string $script 1226 * @param bool $async 1227 */ 1228 protected function execute_js_on_node(NodeElement $node, string $script, bool $async = false): void { 1229 $driver = $this->getSession()->getDriver(); 1230 if (!($driver instanceof \Moodle\BehatExtension\Driver\WebDriver)) { 1231 throw new \coding_exception('Unknown driver'); 1232 } 1233 1234 if (preg_match('/^function[\s\(]/', $script)) { 1235 $script = preg_replace('/;$/', '', $script); 1236 $script = '(' . $script . ')'; 1237 } 1238 1239 $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script); 1240 1241 $webdriver = $driver->getWebDriver(); 1242 1243 $element = $this->get_webdriver_element_from_node_element($node); 1244 if ($async) { 1245 try { 1246 $webdriver->executeAsyncScript($script, [$element]); 1247 } catch (ScriptTimeoutException $e) { 1248 throw new DriverException($e->getMessage(), $e->getCode(), $e); 1249 } 1250 } else { 1251 $webdriver->executeScript($script, [$element]); 1252 } 1253 } 1254 1255 /** 1256 * Translate a Mink NodeElement into a WebDriver Element. 1257 * 1258 * @param NodeElement $node 1259 * @return WebDriverElement 1260 */ 1261 protected function get_webdriver_element_from_node_element(NodeElement $node): WebDriverElement { 1262 return $this->getSession() 1263 ->getDriver() 1264 ->getWebDriver() 1265 ->findElement(WebDriverBy::xpath($node->getXpath())); 1266 } 1267 1268 /** 1269 * Convert page names to URLs for steps like 'When I am on the "[page name]" page'. 1270 * 1271 * You should override this as appropriate for your plugin. The method 1272 * {@link behat_navigation::resolve_core_page_url()} is a good example. 1273 * 1274 * Your overridden method should document the recognised page types with 1275 * a table like this: 1276 * 1277 * Recognised page names are: 1278 * | Page | Description | 1279 * 1280 * @param string $page name of the page, with the component name removed e.g. 'Admin notification'. 1281 * @return moodle_url the corresponding URL. 1282 * @throws Exception with a meaningful error message if the specified page cannot be found. 1283 */ 1284 protected function resolve_page_url(string $page): moodle_url { 1285 throw new Exception('Component "' . get_class($this) . 1286 '" does not support the generic \'When I am on the "' . $page . 1287 '" page\' navigation step.'); 1288 } 1289 1290 /** 1291 * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'. 1292 * 1293 * A typical example might be: 1294 * When I am on the "Test quiz" "mod_quiz > Responses report" page 1295 * which would cause this method in behat_mod_quiz to be called with 1296 * arguments 'Responses report', 'Test quiz'. 1297 * 1298 * You should override this as appropriate for your plugin. The method 1299 * {@link behat_navigation::resolve_core_page_instance_url()} is a good example. 1300 * 1301 * Your overridden method should document the recognised page types with 1302 * a table like this: 1303 * 1304 * Recognised page names are: 1305 * | Type | identifier meaning | Description | 1306 * 1307 * @param string $type identifies which type of page this is, e.g. 'Attempt review'. 1308 * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'. 1309 * @return moodle_url the corresponding URL. 1310 * @throws Exception with a meaningful error message if the specified page cannot be found. 1311 */ 1312 protected function resolve_page_instance_url(string $type, string $identifier): moodle_url { 1313 throw new Exception('Component "' . get_class($this) . 1314 '" does not support the generic \'When I am on the "' . $identifier . 1315 '" "' . $type . '" page\' navigation step.'); 1316 } 1317 1318 /** 1319 * Gets the required timeout in seconds. 1320 * 1321 * @param int $timeout One of the TIMEOUT constants 1322 * @return int Actual timeout (in seconds) 1323 */ 1324 protected static function get_real_timeout(int $timeout) : int { 1325 global $CFG; 1326 if (!empty($CFG->behat_increasetimeout)) { 1327 return $timeout * $CFG->behat_increasetimeout; 1328 } else { 1329 return $timeout; 1330 } 1331 } 1332 1333 /** 1334 * Gets the default timeout. 1335 * 1336 * The timeout for each Behat step (load page, wait for an element to load...). 1337 * 1338 * @return int Timeout in seconds 1339 */ 1340 public static function get_timeout() : int { 1341 return self::get_real_timeout(6); 1342 } 1343 1344 /** 1345 * Gets the reduced timeout. 1346 * 1347 * A reduced timeout for cases where self::get_timeout() is too much 1348 * and a simple $this->getSession()->getPage()->find() could not 1349 * be enough. 1350 * 1351 * @return int Timeout in seconds 1352 */ 1353 public static function get_reduced_timeout() : int { 1354 return self::get_real_timeout(2); 1355 } 1356 1357 /** 1358 * Gets the extended timeout. 1359 * 1360 * A longer timeout for cases where the normal timeout is not enough. 1361 * 1362 * @return int Timeout in seconds 1363 */ 1364 public static function get_extended_timeout() : int { 1365 return self::get_real_timeout(10); 1366 } 1367 1368 /** 1369 * Return a list of the exact named selectors for the component. 1370 * 1371 * Named selectors are what make Behat steps like 1372 * Then I should see "Useful text" in the "General" "fieldset" 1373 * work. Here, "fieldset" is the named selector, and "General" is the locator. 1374 * 1375 * If you override this method in your plugin (e.g. mod_mymod), to define 1376 * new selectors specific to your plugin. For example, if you returned 1377 * new behat_component_named_selector('Thingy', 1378 * [".//some/xpath//img[contains(@alt, %locator%)]/.."]) 1379 * then 1380 * Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy" 1381 * would work. 1382 * 1383 * This method should return a list of {@link behat_component_named_selector} and 1384 * the docs on that class explain how it works. 1385 * 1386 * @return behat_component_named_selector[] 1387 */ 1388 public static function get_exact_named_selectors(): array { 1389 return []; 1390 } 1391 1392 /** 1393 * Return a list of the partial named selectors for the component. 1394 * 1395 * Like the exact named selectors above, but the locator only 1396 * needs to match part of the text. For example, the standard 1397 * "button" is a partial selector, so: 1398 * When I click "Save" "button" 1399 * will activate "Save changes". 1400 * 1401 * @return behat_component_named_selector[] 1402 */ 1403 public static function get_partial_named_selectors(): array { 1404 return []; 1405 } 1406 1407 /** 1408 * Return a list of the Mink named replacements for the component. 1409 * 1410 * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple 1411 * xpaths. 1412 * 1413 * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain 1414 * how it works. 1415 * 1416 * @return behat_component_named_replacement[] 1417 */ 1418 public static function get_named_replacements(): array { 1419 return []; 1420 } 1421 1422 /** 1423 * Evaluate the supplied script in the current session, returning the result. 1424 * 1425 * @param string $script 1426 * @return mixed 1427 */ 1428 public function evaluate_script(string $script) { 1429 return self::evaluate_script_in_session($this->getSession(), $script); 1430 } 1431 1432 /** 1433 * Evaluate the supplied script in the specified session, returning the result. 1434 * 1435 * @param Session $session 1436 * @param string $script 1437 * @return mixed 1438 */ 1439 public static function evaluate_script_in_session(Session $session, string $script) { 1440 self::require_javascript_in_session($session); 1441 1442 return $session->evaluateScript($script); 1443 } 1444 1445 /** 1446 * Execute the supplied script in the current session. 1447 * 1448 * No result will be returned. 1449 * 1450 * @param string $script 1451 */ 1452 public function execute_script(string $script): void { 1453 self::execute_script_in_session($this->getSession(), $script); 1454 } 1455 1456 /** 1457 * Excecute the supplied script in the specified session. 1458 * 1459 * No result will be returned. 1460 * 1461 * @param Session $session 1462 * @param string $script 1463 */ 1464 public static function execute_script_in_session(Session $session, string $script): void { 1465 self::require_javascript_in_session($session); 1466 1467 $session->executeScript($script); 1468 } 1469 1470 /** 1471 * Get the session key for the current session via Javascript. 1472 * 1473 * @return string 1474 */ 1475 public function get_sesskey(): string { 1476 $script = <<<EOF 1477 return (function() { 1478 if (M && M.cfg && M.cfg.sesskey) { 1479 return M.cfg.sesskey; 1480 } 1481 return ''; 1482 })() 1483 EOF; 1484 1485 return $this->evaluate_script($script); 1486 } 1487 1488 /** 1489 * Set the timeout factor for the remaining lifetime of the session. 1490 * 1491 * @param int $factor A multiplication factor to use when calculating the timeout 1492 */ 1493 public function set_test_timeout_factor(int $factor = 1): void { 1494 $driver = $this->getSession()->getDriver(); 1495 1496 if (!$driver instanceof \OAndreyev\Mink\Driver\WebDriver) { 1497 // This is a feature of the OAndreyev MinkWebDriver. 1498 return; 1499 } 1500 1501 // The standard curl timeout is 30 seconds. 1502 // Use get_real_timeout and multiply by the timeout factor to get the final timeout. 1503 $timeout = self::get_real_timeout(30) * 1000 * $factor; 1504 $driver->getWebDriver()->getCommandExecutor()->setRequestTimeout($timeout); 1505 } 1506 1507 /** 1508 * Get the course category id from an identifier. 1509 * 1510 * The category idnumber, and name are checked. 1511 * 1512 * @param string $identifier 1513 * @return int|null 1514 */ 1515 protected function get_category_id(string $identifier): ?int { 1516 global $DB; 1517 1518 $sql = <<<EOF 1519 SELECT id 1520 FROM {course_categories} 1521 WHERE idnumber = :idnumber 1522 OR name = :name 1523 EOF; 1524 1525 $result = $DB->get_field_sql($sql, [ 1526 'idnumber' => $identifier, 1527 'name' => $identifier, 1528 ]); 1529 1530 return $result ?: null; 1531 } 1532 1533 /** 1534 * Get the course id from an identifier. 1535 * 1536 * The course idnumber, shortname, and fullname are checked. 1537 * 1538 * @param string $identifier 1539 * @return int|null 1540 */ 1541 protected function get_course_id(string $identifier): ?int { 1542 global $DB; 1543 1544 $sql = <<<EOF 1545 SELECT id 1546 FROM {course} 1547 WHERE idnumber = :idnumber 1548 OR shortname = :shortname 1549 OR fullname = :fullname 1550 EOF; 1551 1552 $result = $DB->get_field_sql($sql, [ 1553 'idnumber' => $identifier, 1554 'shortname' => $identifier, 1555 'fullname' => $identifier, 1556 ]); 1557 1558 return $result ?: null; 1559 } 1560 1561 /** 1562 * Get the activity course module id from its idnumber. 1563 * 1564 * Note: Only idnumber is supported here, not name at this time. 1565 * 1566 * @param string $identifier 1567 * @return cm_info|null 1568 */ 1569 protected function get_course_module_for_identifier(string $identifier): ?cm_info { 1570 global $DB; 1571 1572 $coursetable = new \core\dml\table('course', 'c', 'c'); 1573 $courseselect = $coursetable->get_field_select(); 1574 $coursefrom = $coursetable->get_from_sql(); 1575 1576 $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); 1577 $cmfrom = $cmtable->get_from_sql(); 1578 1579 $sql = <<<EOF 1580 SELECT {$courseselect}, cm.id as cmid 1581 FROM {$cmfrom} 1582 INNER JOIN {$coursefrom} ON c.id = cm.course 1583 WHERE cm.idnumber = :idnumber 1584 EOF; 1585 1586 $result = $DB->get_record_sql($sql, [ 1587 'idnumber' => $identifier, 1588 ]); 1589 1590 if ($result) { 1591 $course = $coursetable->extract_from_result($result); 1592 return get_fast_modinfo($course)->get_cm($result->cmid); 1593 } 1594 1595 return null; 1596 } 1597 1598 /** 1599 * Get a coursemodule from an activity name or idnumber. 1600 * 1601 * @param string $activity 1602 * @param string $identifier 1603 * @return cm_info 1604 */ 1605 protected function get_cm_by_activity_name(string $activity, string $identifier): cm_info { 1606 global $DB; 1607 1608 $coursetable = new \core\dml\table('course', 'c', 'c'); 1609 $courseselect = $coursetable->get_field_select(); 1610 $coursefrom = $coursetable->get_from_sql(); 1611 1612 $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); 1613 $cmfrom = $cmtable->get_from_sql(); 1614 1615 $acttable = new \core\dml\table($activity, 'a', 'a'); 1616 $actselect = $acttable->get_field_select(); 1617 $actfrom = $acttable->get_from_sql(); 1618 1619 $sql = <<<EOF 1620 SELECT cm.id as cmid, {$courseselect}, {$actselect} 1621 FROM {$cmfrom} 1622 INNER JOIN {$coursefrom} ON c.id = cm.course 1623 INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname 1624 INNER JOIN {$actfrom} ON cm.instance = a.id 1625 WHERE cm.idnumber = :idnumber OR a.name = :name 1626 EOF; 1627 1628 $result = $DB->get_record_sql($sql, [ 1629 'modname' => $activity, 1630 'idnumber' => $identifier, 1631 'name' => $identifier, 1632 ], MUST_EXIST); 1633 1634 $course = $coursetable->extract_from_result($result); 1635 $instancedata = $acttable->extract_from_result($result); 1636 1637 return get_fast_modinfo($course)->get_cm($result->cmid); 1638 } 1639 1640 /** 1641 * Check whether any of the tags availble to the current scope match using the given callable. 1642 * 1643 * This function is typically called from within a Behat Hook, such as BeforeFeature, BeforeScenario, AfterStep, etc. 1644 * 1645 * The callable is used as the second argument to `array_filter()`, and is passed a single string argument for each of the 1646 * tags available in the scope. 1647 * 1648 * The tags passed will include: 1649 * - For a FeatureScope, the Feature tags only 1650 * - For a ScenarioScope, the Feature and Scenario tags 1651 * - For a StepScope, the Feature, Scenario, and Step tags 1652 * 1653 * An example usage may be: 1654 * 1655 * // Note: phpDoc beforeStep attribution not shown. 1656 * public function before_step(StepScope $scope) { 1657 * $callback = function (string $tag): bool { 1658 * return $tag === 'editor_atto' || substr($tag, 0, 5) === 'atto_'; 1659 * }; 1660 * 1661 * if (!self::scope_tags_match($scope, $callback)) { 1662 * return; 1663 * } 1664 * 1665 * // Do something here. 1666 * } 1667 * 1668 * @param HookScope $scope The scope to check 1669 * @param callable $callback The callable to use to check the scope 1670 * @return boolean Whether any of the scope tags match 1671 */ 1672 public static function scope_tags_match(HookScope $scope, callable $callback): bool { 1673 $tags = []; 1674 1675 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\FeatureScope::class)) { 1676 $tags = $scope->getFeature()->getTags(); 1677 } 1678 1679 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\ScenarioScope::class)) { 1680 $tags = array_merge( 1681 $scope->getFeature()->getTags(), 1682 $scope->getScenario()->getTags() 1683 ); 1684 } 1685 1686 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\StepScope::class)) { 1687 $tags = array_merge( 1688 $scope->getFeature()->getTags(), 1689 $scope->getScenario()->getTags(), 1690 $scope->getStep()->getTags() 1691 ); 1692 } 1693 1694 $matches = array_filter($tags, $callback); 1695 1696 return !empty($matches); 1697 } 1698 1699 /** 1700 * Get the user id from an identifier. 1701 * 1702 * The user username and email fields are checked. 1703 * 1704 * @param string $identifier The user's username or email. 1705 * @return int|null The user id or null if not found. 1706 */ 1707 protected function get_user_id_by_identifier(string $identifier): ?int { 1708 global $DB; 1709 1710 $sql = <<<EOF 1711 SELECT id 1712 FROM {user} 1713 WHERE username = :username 1714 OR email = :email 1715 EOF; 1716 1717 $result = $DB->get_field_sql($sql, [ 1718 'username' => $identifier, 1719 'email' => $identifier, 1720 ]); 1721 1722 return $result ?: null; 1723 } 1724 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body