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