See Release Notes
Long Term Support Release
Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 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) { 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) { 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 * @throws DriverException 518 */ 519 protected function require_javascript() { 520 return self::require_javascript_in_session($this->getSession()); 521 } 522 523 /** 524 * Whether Javascript is available in the specified Session. 525 * 526 * @param Session $session 527 * @return boolean 528 */ 529 protected static function running_javascript_in_session(Session $session): bool { 530 return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver'; 531 } 532 533 /** 534 * Require that javascript be available for the specified Session. 535 * 536 * @param Session $session 537 * @throws DriverException 538 */ 539 protected static function require_javascript_in_session(Session $session): void { 540 if (self::running_javascript_in_session($session)) { 541 return; 542 } 543 544 throw new DriverException('Javascript is required'); 545 } 546 547 /** 548 * Checks if the current page is part of the mobile app. 549 * 550 * @return bool True if it's in the app 551 */ 552 protected function is_in_app() : bool { 553 // Cannot be in the app if there's no @app tag on scenario. 554 if (!$this->has_tag('app')) { 555 return false; 556 } 557 558 // Check on page to see if it's an app page. Safest way is to look for added JavaScript. 559 return $this->evaluate_script('return typeof window.behat') === 'object'; 560 } 561 562 /** 563 * Spins around an element until it exists 564 * 565 * @throws ExpectationException 566 * @param string $locator 567 * @param string $selectortype 568 * @return void 569 */ 570 protected function ensure_element_exists($locator, $selectortype) { 571 // Exception if it timesout and the element is still there. 572 $msg = "The '{$locator}' element does not exist and should"; 573 $exception = new ExpectationException($msg, $this->getSession()); 574 575 // Normalise the values in order to perform the search. 576 [ 577 'selector' => $selector, 578 'locator' => $locator, 579 'container' => $container, 580 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 581 582 // It will stop spinning once the find() method returns true. 583 $this->spin( 584 function() use ($selector, $locator, $container) { 585 if ($container->find($selector, $locator)) { 586 return true; 587 } 588 return false; 589 }, 590 [], 591 self::get_extended_timeout(), 592 $exception, 593 true 594 ); 595 } 596 597 /** 598 * Spins until the element does not exist 599 * 600 * @throws ExpectationException 601 * @param string $locator 602 * @param string $selectortype 603 * @return void 604 */ 605 protected function ensure_element_does_not_exist($locator, $selectortype) { 606 // Exception if it timesout and the element is still there. 607 $msg = "The '{$locator}' element exists and should not exist"; 608 $exception = new ExpectationException($msg, $this->getSession()); 609 610 // Normalise the values in order to perform the search. 611 [ 612 'selector' => $selector, 613 'locator' => $locator, 614 'container' => $container, 615 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 616 617 // It will stop spinning once the find() method returns false. 618 $this->spin( 619 function() use ($selector, $locator, $container) { 620 if ($container->find($selector, $locator)) { 621 return false; 622 } 623 return true; 624 }, 625 // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin(). 626 // We cannot nest spins because the outer spin times out before the inner spin completes. 627 [], 628 self::get_extended_timeout(), 629 $exception, 630 true 631 ); 632 } 633 634 /** 635 * Ensures that the provided node is visible and we can interact with it. 636 * 637 * @throws ExpectationException 638 * @param NodeElement $node 639 * @return void Throws an exception if it times out without the element being visible 640 */ 641 protected function ensure_node_is_visible($node) { 642 643 if (!$this->running_javascript()) { 644 return; 645 } 646 647 // Exception if it timesout and the element is still there. 648 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 649 $exception = new ExpectationException($msg, $this->getSession()); 650 651 // It will stop spinning once the isVisible() method returns true. 652 $this->spin( 653 function($context, $args) { 654 if ($args->isVisible()) { 655 return true; 656 } 657 return false; 658 }, 659 $node, 660 self::get_extended_timeout(), 661 $exception, 662 true 663 ); 664 } 665 666 /** 667 * Ensures that the provided node has a attribute value set. This step can be used to check if specific 668 * JS has finished modifying the node. 669 * 670 * @throws ExpectationException 671 * @param NodeElement $node 672 * @param string $attribute attribute name 673 * @param string $attributevalue attribute value to check. 674 * @return void Throws an exception if it times out without the element being visible 675 */ 676 protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) { 677 678 if (!$this->running_javascript()) { 679 return; 680 } 681 682 // Exception if it timesout and the element is still there. 683 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 684 $exception = new ExpectationException($msg, $this->getSession()); 685 686 // It will stop spinning once the $args[1]) == $args[2], and method returns true. 687 $this->spin( 688 function($context, $args) { 689 if ($args[0]->getAttribute($args[1]) == $args[2]) { 690 return true; 691 } 692 return false; 693 }, 694 array($node, $attribute, $attributevalue), 695 self::get_extended_timeout(), 696 $exception, 697 true 698 ); 699 } 700 701 /** 702 * Ensures that the provided element is visible and we can interact with it. 703 * 704 * Returns the node in case other actions are interested in using it. 705 * 706 * @throws ExpectationException 707 * @param string $element 708 * @param string $selectortype 709 * @return NodeElement Throws an exception if it times out without being visible 710 */ 711 protected function ensure_element_is_visible($element, $selectortype) { 712 713 if (!$this->running_javascript()) { 714 return; 715 } 716 717 $node = $this->get_selected_node($selectortype, $element); 718 $this->ensure_node_is_visible($node); 719 720 return $node; 721 } 722 723 /** 724 * Ensures that all the page's editors are loaded. 725 * 726 * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more. 727 * @throws ElementNotFoundException 728 * @throws ExpectationException 729 * @return void 730 */ 731 protected function ensure_editors_are_loaded() { 732 global $CFG; 733 734 if (empty($CFG->behat_usedeprecated)) { 735 debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.'); 736 } 737 return; 738 } 739 740 /** 741 * Checks if the current scenario, or its feature, has a specified tag. 742 * 743 * @param string $tag Tag to check 744 * @return bool True if the tag exists in scenario or feature 745 */ 746 public function has_tag(string $tag) : bool { 747 return array_key_exists($tag, behat_hooks::get_tags_for_scenario()); 748 } 749 750 /** 751 * Change browser window size. 752 * - small: 640x480 753 * - medium: 1024x768 754 * - large: 2560x1600 755 * 756 * @param string $windowsize size of window. 757 * @param bool $viewport If true, changes viewport rather than window size 758 * @throws ExpectationException 759 */ 760 protected function resize_window($windowsize, $viewport = false) { 761 global $CFG; 762 763 // Non JS don't support resize window. 764 if (!$this->running_javascript()) { 765 return; 766 } 767 768 switch ($windowsize) { 769 case "small": 770 $width = 1024; 771 $height = 768; 772 break; 773 case "medium": 774 $width = 1366; 775 $height = 768; 776 break; 777 case "large": 778 $width = 2560; 779 $height = 1600; 780 break; 781 default: 782 preg_match('/^(\d+x\d+)$/', $windowsize, $matches); 783 if (empty($matches) || (count($matches) != 2)) { 784 throw new ExpectationException("Invalid screen size, can't resize", $this->getSession()); 785 } 786 $size = explode('x', $windowsize); 787 $width = (int) $size[0]; 788 $height = (int) $size[1]; 789 } 790 791 if (isset($CFG->behat_window_size_modifier) && is_numeric($CFG->behat_window_size_modifier)) { 792 $width *= $CFG->behat_window_size_modifier; 793 $height *= $CFG->behat_window_size_modifier; 794 } 795 796 if ($viewport) { 797 // When setting viewport size, we set it so that the document width will be exactly 798 // as specified, assuming that there is a vertical scrollbar. (In cases where there is 799 // no scrollbar it will be slightly wider. We presume this is rare and predictable.) 800 // The window inner height will be as specified, which means the available viewport will 801 // actually be smaller if there is a horizontal scrollbar. We assume that horizontal 802 // scrollbars are rare so this doesn't matter. 803 $js = <<<EOF 804 return (function() { 805 var before = document.body.style.overflowY; 806 document.body.style.overflowY = "scroll"; 807 var result = {}; 808 result.x = window.outerWidth - document.body.offsetWidth; 809 result.y = window.outerHeight - window.innerHeight; 810 document.body.style.overflowY = before; 811 return result; 812 })(); 813 EOF; 814 $offset = $this->evaluate_script($js); 815 $width += $offset['x']; 816 $height += $offset['y']; 817 } 818 819 $this->getSession()->getDriver()->resizeWindow($width, $height); 820 } 821 822 /** 823 * Waits for all the JS to be loaded. 824 * 825 * @return bool Whether any JS is still pending completion. 826 */ 827 public function wait_for_pending_js() { 828 return static::wait_for_pending_js_in_session($this->getSession()); 829 } 830 831 /** 832 * Waits for all the JS to be loaded. 833 * 834 * @param Session $session The Mink Session where JS can be run 835 * @return bool Whether any JS is still pending completion. 836 */ 837 public static function wait_for_pending_js_in_session(Session $session) { 838 if (!self::running_javascript_in_session($session)) { 839 // JS is not available therefore there is nothing to wait for. 840 return false; 841 } 842 843 // We don't use behat_base::spin() here as we don't want to end up with an exception 844 // if the page & JSs don't finish loading properly. 845 for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) { 846 $pending = ''; 847 try { 848 $jscode = trim(preg_replace('/\s+/', ' ', ' 849 return (function() { 850 if (document.readyState !== "complete") { 851 return "incomplete"; 852 } 853 854 if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") { 855 return ""; 856 } 857 858 return M.util.pending_js.join(":"); 859 })()')); 860 $pending = self::evaluate_script_in_session($session, $jscode); 861 } catch (NoSuchWindowException $nsw) { 862 // We catch an exception here, in case we just closed the window we were interacting with. 863 // No javascript is running if there is no window right? 864 $pending = ''; 865 } catch (UnknownError $e) { 866 // M is not defined when the window or the frame don't exist anymore. 867 if (strstr($e->getMessage(), 'M is not defined') != false) { 868 $pending = ''; 869 } 870 } 871 872 // If there are no pending JS we stop waiting. 873 if ($pending === '') { 874 return true; 875 } 876 877 // 0.1 seconds. 878 usleep(100000); 879 } 880 881 // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions(). 882 // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds 883 // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the 884 // number of JS pending code and JS completed code will not match and we will reach this point. 885 throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . 886 self::get_extended_timeout() . 887 ' seconds. There is a Javascript error or the code is extremely slow (' . $pending . 888 '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.'); 889 } 890 891 /** 892 * Internal step definition to find exceptions, debugging() messages and PHP debug messages. 893 * 894 * Part of behat_hooks class as is part of the testing framework, is auto-executed 895 * after each step so no features will splicitly use it. 896 * 897 * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception. 898 * @see Moodle\BehatExtension\Tester\MoodleStepTester 899 */ 900 public function look_for_exceptions() { 901 // Wrap in try in case we were interacting with a closed window. 902 try { 903 904 // Exceptions. 905 $exceptionsxpath = "//div[@data-rel='fatalerror']"; 906 // Debugging messages. 907 $debuggingxpath = "//div[@data-rel='debugging']"; 908 // PHP debug messages. 909 $phperrorxpath = "//div[@data-rel='phpdebugmessage']"; 910 // Any other backtrace. 911 $othersxpath = "(//*[contains(., ': call to ')])[1]"; 912 913 $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath); 914 $joinedxpath = implode(' | ', $xpaths); 915 916 // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check 917 // is faster than to send the 4 xpath queries for each step. 918 if (!$this->getSession()->getDriver()->find($joinedxpath)) { 919 // Check if we have recorded any errors in driver process. 920 $phperrors = behat_get_shutdown_process_errors(); 921 if (!empty($phperrors)) { 922 foreach ($phperrors as $error) { 923 $errnostring = behat_get_error_string($error['type']); 924 $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line']; 925 } 926 $msg = "PHP errors found:\n" . implode("\n", $msgs); 927 throw new \Exception(htmlentities($msg)); 928 } 929 930 return; 931 } 932 933 // Exceptions. 934 if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) { 935 936 // Getting the debugging info and the backtrace. 937 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error'); 938 // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class. 939 if (empty($errorinfoboxes)) { 940 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger'); 941 } 942 // If errorinfoboxes is empty, try find notifytiny (original) class. 943 if (empty($errorinfoboxes)) { 944 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny'); 945 } 946 947 // If errorinfoboxes is empty, try find ajax/JS exception in dialogue. 948 if (empty($errorinfoboxes)) { 949 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message'); 950 951 // If ajax/JS exception. 952 if ($errorinfoboxes) { 953 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()); 954 } 955 956 } else { 957 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" . 958 $this->get_debug_text($errorinfoboxes[1]->getHtml()); 959 } 960 961 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo; 962 throw new \Exception(html_entity_decode($msg)); 963 } 964 965 // Debugging messages. 966 if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) { 967 $msgs = array(); 968 foreach ($debuggingmessages as $debuggingmessage) { 969 $msgs[] = $this->get_debug_text($debuggingmessage->getHtml()); 970 } 971 $msg = "debugging() message/s found:\n" . implode("\n", $msgs); 972 throw new \Exception(html_entity_decode($msg)); 973 } 974 975 // PHP debug messages. 976 if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) { 977 978 $msgs = array(); 979 foreach ($phpmessages as $phpmessage) { 980 $msgs[] = $this->get_debug_text($phpmessage->getHtml()); 981 } 982 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs); 983 throw new \Exception(html_entity_decode($msg)); 984 } 985 986 // Any other backtrace. 987 // First looking through xpath as it is faster than get and parse the whole page contents, 988 // we get the contents and look for matches once we found something to suspect that there is a backtrace. 989 if ($this->getSession()->getDriver()->find($othersxpath)) { 990 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/'; 991 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) { 992 $msgs = array(); 993 foreach ($backtraces[0] as $backtrace) { 994 $msgs[] = $backtrace . '()'; 995 } 996 $msg = "Other backtraces found:\n" . implode("\n", $msgs); 997 throw new \Exception(htmlentities($msg)); 998 } 999 } 1000 1001 } catch (NoSuchWindowException $e) { 1002 // If we were interacting with a popup window it will not exists after closing it. 1003 } catch (DriverException $e) { 1004 // Same reason as above. 1005 } 1006 } 1007 1008 /** 1009 * Converts HTML tags to line breaks to display the info in CLI 1010 * 1011 * @param string $html 1012 * @return string 1013 */ 1014 protected function get_debug_text($html) { 1015 1016 // Replacing HTML tags for new lines and keeping only the text. 1017 $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html); 1018 return preg_replace("/(\n)+/s", "\n", $notags); 1019 } 1020 1021 /** 1022 * Helper function to execute api in a given context. 1023 * 1024 * @param string $contextapi context in which api is defined. 1025 * @param array $params list of params to pass. 1026 * @throws Exception 1027 */ 1028 protected function execute($contextapi, $params = array()) { 1029 if (!is_array($params)) { 1030 $params = array($params); 1031 } 1032 1033 // Get required context and execute the api. 1034 $contextapi = explode("::", $contextapi); 1035 $context = behat_context_helper::get($contextapi[0]); 1036 call_user_func_array(array($context, $contextapi[1]), $params); 1037 1038 // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results. 1039 // Don't make them optional for performance reasons. 1040 1041 // Wait for pending js. 1042 $this->wait_for_pending_js(); 1043 1044 // Look for exceptions. 1045 $this->look_for_exceptions(); 1046 } 1047 1048 /** 1049 * Execute a function in a specific behat context. 1050 * 1051 * For example, to call the 'set_editor_value' function for all editors, you would call: 1052 * 1053 * behat_base::execute_in_matching_contexts('editor', 'set_editor_value', ['Some value']); 1054 * 1055 * This would find all behat contexts whose class name starts with 'behat_editor_' and 1056 * call the 'set_editor_value' function on that context. 1057 * 1058 * @param string $prefix 1059 * @param string $method 1060 * @param array $params 1061 */ 1062 public static function execute_in_matching_contexts(string $prefix, string $method, array $params): void { 1063 $contexts = behat_context_helper::get_prefixed_contexts("behat_{$prefix}_"); 1064 foreach ($contexts as $context) { 1065 if (method_exists($context, $method) && is_callable([$context, $method])) { 1066 call_user_func_array([$context, $method], $params); 1067 } 1068 } 1069 } 1070 1071 /** 1072 * Get the actual user in the behat session (note $USER does not correspond to the behat session's user). 1073 * @return mixed 1074 * @throws coding_exception 1075 */ 1076 protected function get_session_user() { 1077 global $DB; 1078 1079 $sid = $this->getSession()->getCookie('MoodleSession'); 1080 if (empty($sid)) { 1081 throw new coding_exception('failed to get moodle session'); 1082 } 1083 $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]); 1084 if (empty($userid)) { 1085 throw new coding_exception('failed to get user from seession id '.$sid); 1086 } 1087 return $DB->get_record('user', ['id' => $userid]); 1088 } 1089 1090 /** 1091 * Set current $USER, reset access cache. 1092 * 1093 * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some 1094 * API's might rely on the logged user to take some action. 1095 * 1096 * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid 1097 */ 1098 public static function set_user($user = null) { 1099 global $DB; 1100 1101 if (is_object($user)) { 1102 $user = clone($user); 1103 } else if (!$user) { 1104 // Assign valid data to admin user (some generator-related code needs a valid user). 1105 $user = $DB->get_record('user', array('username' => 'admin')); 1106 } else { 1107 $user = $DB->get_record('user', array('id' => $user)); 1108 } 1109 unset($user->description); 1110 unset($user->access); 1111 unset($user->preference); 1112 1113 // Ensure session is empty, as it may contain caches and user specific info. 1114 \core\session\manager::init_empty_session(); 1115 1116 \core\session\manager::set_user($user); 1117 } 1118 1119 /** 1120 * Gets the internal moodle context id from the context reference. 1121 * 1122 * The context reference changes depending on the context 1123 * level, it can be the system, a user, a category, a course or 1124 * a module. 1125 * 1126 * @throws Exception 1127 * @param string $levelname The context level string introduced by the test writer 1128 * @param string $contextref The context reference introduced by the test writer 1129 * @return context 1130 */ 1131 public static function get_context(string $levelname, string $contextref): context { 1132 global $DB; 1133 1134 // Getting context levels and names (we will be using the English ones as it is the test site language). 1135 $contextlevels = context_helper::get_all_levels(); 1136 $contextnames = array(); 1137 foreach ($contextlevels as $level => $classname) { 1138 $contextnames[context_helper::get_level_name($level)] = $level; 1139 } 1140 1141 if (empty($contextnames[$levelname])) { 1142 throw new Exception('The specified "' . $levelname . '" context level does not exist'); 1143 } 1144 $contextlevel = $contextnames[$levelname]; 1145 1146 // Return it, we don't need to look for other internal ids. 1147 if ($contextlevel == CONTEXT_SYSTEM) { 1148 return context_system::instance(); 1149 } 1150 1151 switch ($contextlevel) { 1152 1153 case CONTEXT_USER: 1154 $instanceid = $DB->get_field('user', 'id', array('username' => $contextref)); 1155 break; 1156 1157 case CONTEXT_COURSECAT: 1158 $instanceid = $DB->get_field('course_categories', 'id', array('idnumber' => $contextref)); 1159 break; 1160 1161 case CONTEXT_COURSE: 1162 $instanceid = $DB->get_field('course', 'id', array('shortname' => $contextref)); 1163 break; 1164 1165 case CONTEXT_MODULE: 1166 $instanceid = $DB->get_field('course_modules', 'id', array('idnumber' => $contextref)); 1167 break; 1168 1169 default: 1170 break; 1171 } 1172 1173 $contextclass = $contextlevels[$contextlevel]; 1174 if (!$context = $contextclass::instance($instanceid, IGNORE_MISSING)) { 1175 throw new Exception('The specified "' . $contextref . '" context reference does not exist'); 1176 } 1177 1178 return $context; 1179 } 1180 1181 /** 1182 * Trigger click on node via javascript instead of actually clicking on it via pointer. 1183 * 1184 * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via 1185 * the pointer may accidentally cause a click on the wrong element. 1186 * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled. 1187 * @param NodeElement $node 1188 */ 1189 protected function js_trigger_click($node) { 1190 if (!$this->running_javascript()) { 1191 $node->click(); 1192 } 1193 $driver = $this->getSession()->getDriver(); 1194 if ($driver instanceof \Moodle\BehatExtension\Driver\WebDriver) { 1195 $this->execute_js_on_node($node, '{{ELEMENT}}.click();'); 1196 } else { 1197 $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked. 1198 $driver->click($node->getXpath()); 1199 } 1200 } 1201 1202 /** 1203 * Execute JS on the specified NodeElement. 1204 * 1205 * @param NodeElement $node 1206 * @param string $script 1207 * @param bool $async 1208 */ 1209 protected function execute_js_on_node(NodeElement $node, string $script, bool $async = false): void { 1210 $driver = $this->getSession()->getDriver(); 1211 if (!($driver instanceof \Moodle\BehatExtension\Driver\WebDriver)) { 1212 throw new \coding_exception('Unknown driver'); 1213 } 1214 1215 if (preg_match('/^function[\s\(]/', $script)) { 1216 $script = preg_replace('/;$/', '', $script); 1217 $script = '(' . $script . ')'; 1218 } 1219 1220 $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script); 1221 1222 $webdriver = $driver->getWebDriver(); 1223 1224 $element = $this->get_webdriver_element_from_node_element($node); 1225 if ($async) { 1226 try { 1227 $webdriver->executeAsyncScript($script, [$element]); 1228 } catch (ScriptTimeoutException $e) { 1229 throw new DriverException($e->getMessage(), $e->getCode(), $e); 1230 } 1231 } else { 1232 $webdriver->executeScript($script, [$element]); 1233 } 1234 } 1235 1236 /** 1237 * Translate a Mink NodeElement into a WebDriver Element. 1238 * 1239 * @param NodeElement $node 1240 * @return WebDriverElement 1241 */ 1242 protected function get_webdriver_element_from_node_element(NodeElement $node): WebDriverElement { 1243 return $this->getSession() 1244 ->getDriver() 1245 ->getWebDriver() 1246 ->findElement(WebDriverBy::xpath($node->getXpath())); 1247 } 1248 1249 /** 1250 * Convert page names to URLs for steps like 'When I am on the "[page name]" page'. 1251 * 1252 * You should override this as appropriate for your plugin. The method 1253 * {@link behat_navigation::resolve_core_page_url()} is a good example. 1254 * 1255 * Your overridden method should document the recognised page types with 1256 * a table like this: 1257 * 1258 * Recognised page names are: 1259 * | Page | Description | 1260 * 1261 * @param string $page name of the page, with the component name removed e.g. 'Admin notification'. 1262 * @return moodle_url the corresponding URL. 1263 * @throws Exception with a meaningful error message if the specified page cannot be found. 1264 */ 1265 protected function resolve_page_url(string $page): moodle_url { 1266 throw new Exception('Component "' . get_class($this) . 1267 '" does not support the generic \'When I am on the "' . $page . 1268 '" page\' navigation step.'); 1269 } 1270 1271 /** 1272 * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'. 1273 * 1274 * A typical example might be: 1275 * When I am on the "Test quiz" "mod_quiz > Responses report" page 1276 * which would cause this method in behat_mod_quiz to be called with 1277 * arguments 'Responses report', 'Test quiz'. 1278 * 1279 * You should override this as appropriate for your plugin. The method 1280 * {@link behat_navigation::resolve_core_page_instance_url()} is a good example. 1281 * 1282 * Your overridden method should document the recognised page types with 1283 * a table like this: 1284 * 1285 * Recognised page names are: 1286 * | Type | identifier meaning | Description | 1287 * 1288 * @param string $type identifies which type of page this is, e.g. 'Attempt review'. 1289 * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'. 1290 * @return moodle_url the corresponding URL. 1291 * @throws Exception with a meaningful error message if the specified page cannot be found. 1292 */ 1293 protected function resolve_page_instance_url(string $type, string $identifier): moodle_url { 1294 throw new Exception('Component "' . get_class($this) . 1295 '" does not support the generic \'When I am on the "' . $identifier . 1296 '" "' . $type . '" page\' navigation step.'); 1297 } 1298 1299 /** 1300 * Gets the required timeout in seconds. 1301 * 1302 * @param int $timeout One of the TIMEOUT constants 1303 * @return int Actual timeout (in seconds) 1304 */ 1305 protected static function get_real_timeout(int $timeout) : int { 1306 global $CFG; 1307 if (!empty($CFG->behat_increasetimeout)) { 1308 return $timeout * $CFG->behat_increasetimeout; 1309 } else { 1310 return $timeout; 1311 } 1312 } 1313 1314 /** 1315 * Gets the default timeout. 1316 * 1317 * The timeout for each Behat step (load page, wait for an element to load...). 1318 * 1319 * @return int Timeout in seconds 1320 */ 1321 public static function get_timeout() : int { 1322 return self::get_real_timeout(6); 1323 } 1324 1325 /** 1326 * Gets the reduced timeout. 1327 * 1328 * A reduced timeout for cases where self::get_timeout() is too much 1329 * and a simple $this->getSession()->getPage()->find() could not 1330 * be enough. 1331 * 1332 * @return int Timeout in seconds 1333 */ 1334 public static function get_reduced_timeout() : int { 1335 return self::get_real_timeout(2); 1336 } 1337 1338 /** 1339 * Gets the extended timeout. 1340 * 1341 * A longer timeout for cases where the normal timeout is not enough. 1342 * 1343 * @return int Timeout in seconds 1344 */ 1345 public static function get_extended_timeout() : int { 1346 return self::get_real_timeout(10); 1347 } 1348 1349 /** 1350 * Return a list of the exact named selectors for the component. 1351 * 1352 * Named selectors are what make Behat steps like 1353 * Then I should see "Useful text" in the "General" "fieldset" 1354 * work. Here, "fieldset" is the named selector, and "General" is the locator. 1355 * 1356 * If you override this method in your plugin (e.g. mod_mymod), to define 1357 * new selectors specific to your plugin. For example, if you returned 1358 * new behat_component_named_selector('Thingy', 1359 * [".//some/xpath//img[contains(@alt, %locator%)]/.."]) 1360 * then 1361 * Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy" 1362 * would work. 1363 * 1364 * This method should return a list of {@link behat_component_named_selector} and 1365 * the docs on that class explain how it works. 1366 * 1367 * @return behat_component_named_selector[] 1368 */ 1369 public static function get_exact_named_selectors(): array { 1370 return []; 1371 } 1372 1373 /** 1374 * Return a list of the partial named selectors for the component. 1375 * 1376 * Like the exact named selectors above, but the locator only 1377 * needs to match part of the text. For example, the standard 1378 * "button" is a partial selector, so: 1379 * When I click "Save" "button" 1380 * will activate "Save changes". 1381 * 1382 * @return behat_component_named_selector[] 1383 */ 1384 public static function get_partial_named_selectors(): array { 1385 return []; 1386 } 1387 1388 /** 1389 * Return a list of the Mink named replacements for the component. 1390 * 1391 * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple 1392 * xpaths. 1393 * 1394 * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain 1395 * how it works. 1396 * 1397 * @return behat_component_named_replacement[] 1398 */ 1399 public static function get_named_replacements(): array { 1400 return []; 1401 } 1402 1403 /** 1404 * Evaluate the supplied script in the current session, returning the result. 1405 * 1406 * @param string $script 1407 * @return mixed 1408 */ 1409 public function evaluate_script(string $script) { 1410 return self::evaluate_script_in_session($this->getSession(), $script); 1411 } 1412 1413 /** 1414 * Evaluate the supplied script in the specified session, returning the result. 1415 * 1416 * @param Session $session 1417 * @param string $script 1418 * @return mixed 1419 */ 1420 public static function evaluate_script_in_session(Session $session, string $script) { 1421 self::require_javascript_in_session($session); 1422 1423 return $session->evaluateScript($script); 1424 } 1425 1426 /** 1427 * Execute the supplied script in the current session. 1428 * 1429 * No result will be returned. 1430 * 1431 * @param string $script 1432 */ 1433 public function execute_script(string $script): void { 1434 self::execute_script_in_session($this->getSession(), $script); 1435 } 1436 1437 /** 1438 * Excecute the supplied script in the specified session. 1439 * 1440 * No result will be returned. 1441 * 1442 * @param Session $session 1443 * @param string $script 1444 */ 1445 public static function execute_script_in_session(Session $session, string $script): void { 1446 self::require_javascript_in_session($session); 1447 1448 $session->executeScript($script); 1449 } 1450 1451 /** 1452 * Get the session key for the current session via Javascript. 1453 * 1454 * @return string 1455 */ 1456 public function get_sesskey(): string { 1457 $script = <<<EOF 1458 return (function() { 1459 if (M && M.cfg && M.cfg.sesskey) { 1460 return M.cfg.sesskey; 1461 } 1462 return ''; 1463 })() 1464 EOF; 1465 1466 return $this->evaluate_script($script); 1467 } 1468 1469 /** 1470 * Set the timeout factor for the remaining lifetime of the session. 1471 * 1472 * @param int $factor A multiplication factor to use when calculating the timeout 1473 */ 1474 public function set_test_timeout_factor(int $factor = 1): void { 1475 $driver = $this->getSession()->getDriver(); 1476 1477 if (!$driver instanceof \OAndreyev\Mink\Driver\WebDriver) { 1478 // This is a feature of the OAndreyev MinkWebDriver. 1479 return; 1480 } 1481 1482 // The standard curl timeout is 30 seconds. 1483 // Use get_real_timeout and multiply by the timeout factor to get the final timeout. 1484 $timeout = self::get_real_timeout(30) * 1000 * $factor; 1485 $driver->getWebDriver()->getCommandExecutor()->setRequestTimeout($timeout); 1486 } 1487 1488 /** 1489 * Get the course category id from an identifier. 1490 * 1491 * The category idnumber, and name are checked. 1492 * 1493 * @param string $identifier 1494 * @return int|null 1495 */ 1496 protected function get_category_id(string $identifier): ?int { 1497 global $DB; 1498 1499 $sql = <<<EOF 1500 SELECT id 1501 FROM {course_categories} 1502 WHERE idnumber = :idnumber 1503 OR name = :name 1504 EOF; 1505 1506 $result = $DB->get_field_sql($sql, [ 1507 'idnumber' => $identifier, 1508 'name' => $identifier, 1509 ]); 1510 1511 return $result ?: null; 1512 } 1513 1514 /** 1515 * Get the course id from an identifier. 1516 * 1517 * The course idnumber, shortname, and fullname are checked. 1518 * 1519 * @param string $identifier 1520 * @return int|null 1521 */ 1522 protected function get_course_id(string $identifier): ?int { 1523 global $DB; 1524 1525 $sql = <<<EOF 1526 SELECT id 1527 FROM {course} 1528 WHERE idnumber = :idnumber 1529 OR shortname = :shortname 1530 OR fullname = :fullname 1531 EOF; 1532 1533 $result = $DB->get_field_sql($sql, [ 1534 'idnumber' => $identifier, 1535 'shortname' => $identifier, 1536 'fullname' => $identifier, 1537 ]); 1538 1539 return $result ?: null; 1540 } 1541 1542 /** 1543 * Get the activity course module id from its idnumber. 1544 * 1545 * Note: Only idnumber is supported here, not name at this time. 1546 * 1547 * @param string $identifier 1548 * @return cm_info|null 1549 */ 1550 protected function get_course_module_for_identifier(string $identifier): ?cm_info { 1551 global $DB; 1552 1553 $coursetable = new \core\dml\table('course', 'c', 'c'); 1554 $courseselect = $coursetable->get_field_select(); 1555 $coursefrom = $coursetable->get_from_sql(); 1556 1557 $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); 1558 $cmfrom = $cmtable->get_from_sql(); 1559 1560 $sql = <<<EOF 1561 SELECT {$courseselect}, cm.id as cmid 1562 FROM {$cmfrom} 1563 INNER JOIN {$coursefrom} ON c.id = cm.course 1564 WHERE cm.idnumber = :idnumber 1565 EOF; 1566 1567 $result = $DB->get_record_sql($sql, [ 1568 'idnumber' => $identifier, 1569 ]); 1570 1571 if ($result) { 1572 $course = $coursetable->extract_from_result($result); 1573 return get_fast_modinfo($course)->get_cm($result->cmid); 1574 } 1575 1576 return null; 1577 } 1578 1579 /** 1580 * Get a coursemodule from an activity name or idnumber. 1581 * 1582 * @param string $activity 1583 * @param string $identifier 1584 * @return cm_info 1585 */ 1586 protected function get_cm_by_activity_name(string $activity, string $identifier): cm_info { 1587 global $DB; 1588 1589 $coursetable = new \core\dml\table('course', 'c', 'c'); 1590 $courseselect = $coursetable->get_field_select(); 1591 $coursefrom = $coursetable->get_from_sql(); 1592 1593 $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); 1594 $cmfrom = $cmtable->get_from_sql(); 1595 1596 $acttable = new \core\dml\table($activity, 'a', 'a'); 1597 $actselect = $acttable->get_field_select(); 1598 $actfrom = $acttable->get_from_sql(); 1599 1600 $sql = <<<EOF 1601 SELECT cm.id as cmid, {$courseselect}, {$actselect} 1602 FROM {$cmfrom} 1603 INNER JOIN {$coursefrom} ON c.id = cm.course 1604 INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname 1605 INNER JOIN {$actfrom} ON cm.instance = a.id 1606 WHERE cm.idnumber = :idnumber OR a.name = :name 1607 EOF; 1608 1609 $result = $DB->get_record_sql($sql, [ 1610 'modname' => $activity, 1611 'idnumber' => $identifier, 1612 'name' => $identifier, 1613 ], MUST_EXIST); 1614 1615 $course = $coursetable->extract_from_result($result); 1616 $instancedata = $acttable->extract_from_result($result); 1617 1618 return get_fast_modinfo($course)->get_cm($result->cmid); 1619 } 1620 1621 /** 1622 * Check whether any of the tags availble to the current scope match using the given callable. 1623 * 1624 * This function is typically called from within a Behat Hook, such as BeforeFeature, BeforeScenario, AfterStep, etc. 1625 * 1626 * The callable is used as the second argument to `array_filter()`, and is passed a single string argument for each of the 1627 * tags available in the scope. 1628 * 1629 * The tags passed will include: 1630 * - For a FeatureScope, the Feature tags only 1631 * - For a ScenarioScope, the Feature and Scenario tags 1632 * - For a StepScope, the Feature, Scenario, and Step tags 1633 * 1634 * An example usage may be: 1635 * 1636 * // Note: phpDoc beforeStep attribution not shown. 1637 * public function before_step(StepScope $scope) { 1638 * $callback = function (string $tag): bool { 1639 * return $tag === 'editor_atto' || substr($tag, 0, 5) === 'atto_'; 1640 * }; 1641 * 1642 * if (!self::scope_tags_match($scope, $callback)) { 1643 * return; 1644 * } 1645 * 1646 * // Do something here. 1647 * } 1648 * 1649 * @param HookScope $scope The scope to check 1650 * @param callable $callback The callable to use to check the scope 1651 * @return boolean Whether any of the scope tags match 1652 */ 1653 public static function scope_tags_match(HookScope $scope, callable $callback): bool { 1654 $tags = []; 1655 1656 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\FeatureScope::class)) { 1657 $tags = $scope->getFeature()->getTags(); 1658 } 1659 1660 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\ScenarioScope::class)) { 1661 $tags = array_merge( 1662 $scope->getFeature()->getTags(), 1663 $scope->getScenario()->getTags() 1664 ); 1665 } 1666 1667 if (is_subclass_of($scope, \Behat\Behat\Hook\Scope\StepScope::class)) { 1668 $tags = array_merge( 1669 $scope->getFeature()->getTags(), 1670 $scope->getScenario()->getTags(), 1671 $scope->getStep()->getTags() 1672 ); 1673 } 1674 1675 $matches = array_filter($tags, $callback); 1676 1677 return !empty($matches); 1678 } 1679 1680 /** 1681 * Get the user id from an identifier. 1682 * 1683 * The user username and email fields are checked. 1684 * 1685 * @param string $identifier The user's username or email. 1686 * @return int|null The user id or null if not found. 1687 */ 1688 protected function get_user_id_by_identifier(string $identifier): ?int { 1689 global $DB; 1690 1691 $sql = <<<EOF 1692 SELECT id 1693 FROM {user} 1694 WHERE username = :username 1695 OR email = :email 1696 EOF; 1697 1698 $result = $DB->get_field_sql($sql, [ 1699 'username' => $identifier, 1700 'email' => $identifier, 1701 ]); 1702 1703 return $result ?: null; 1704 } 1705 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body