Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]
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 Facebook\WebDriver\Exception\ScriptTimeoutException; 34 use Facebook\WebDriver\WebDriverBy; 35 use Facebook\WebDriver\WebDriverElement; 36 37 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. 38 39 require_once (__DIR__ . '/component_named_replacement.php'); 40 require_once (__DIR__ . '/component_named_selector.php'); 41 42 // Alias the Facebook\WebDriver\WebDriverKeys class to behat_keys for better b/c with the older Instaclick driver. 43 class_alias('Facebook\WebDriver\WebDriverKeys', 'behat_keys'); 44 45 /** 46 * A trait containing functionality used by the behat base context, and form fields. 47 * 48 * This trait should be used by the behat_base context, and behat form fields, and it should be paired with the 49 * behat_session_interface interface. 50 * 51 * It should not be necessary to use this trait, and the behat_session_interface interface in normal circumstances. 52 * 53 * @package core 54 * @category test 55 * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk> 56 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 57 */ 58 trait behat_session_trait { 59 60 /** 61 * Locates url, based on provided path. 62 * Override to provide custom routing mechanism. 63 * 64 * @see Behat\MinkExtension\Context\MinkContext 65 * @param string $path 66 * @return string 67 */ 68 protected function locate_path($path) { 69 $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/'; 70 return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path; 71 } 72 73 /** 74 * Returns the first matching element. 75 * 76 * @link http://mink.behat.org/#traverse-the-page-selectors 77 * @param string $selector The selector type (css, xpath, named...) 78 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... 79 * @param Exception $exception Otherwise we throw exception with generic info 80 * @param NodeElement $node Spins around certain DOM node instead of the whole page 81 * @param int $timeout Forces a specific time out (in seconds). 82 * @return NodeElement 83 */ 84 protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) { 85 if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) { 86 // Support a NodeElement being passed in for use in step chaining. 87 return $locator; 88 } 89 90 // Returns the first match. 91 $items = $this->find_all($selector, $locator, $exception, $node, $timeout); 92 return count($items) ? reset($items) : null; 93 } 94 95 /** 96 * Returns all matching elements. 97 * 98 * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method. 99 * 100 * @link http://mink.behat.org/#traverse-the-page-selectors 101 * @param string $selector The selector type (css, xpath, named...) 102 * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... 103 * @param Exception $exception Otherwise we throw expcetion with generic info 104 * @param NodeElement $container Restrict the search to just children of the specified container 105 * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied. 106 * @return array NodeElements list 107 */ 108 protected function find_all($selector, $locator, $exception = false, $container = false, $timeout = false) { 109 // Throw exception, so dev knows it is not supported. 110 if ($selector === 'named') { 111 $exception = 'Using the "named" selector is deprecated as of 3.1. ' 112 .' Use the "named_partial" or use the "named_exact" selector instead.'; 113 throw new ExpectationException($exception, $this->getSession()); 114 } 115 116 // Generic info. 117 if (!$exception) { 118 // With named selectors we can be more specific. 119 if (($selector == 'named_exact') || ($selector == 'named_partial')) { 120 $exceptiontype = $locator[0]; 121 $exceptionlocator = $locator[1]; 122 123 // If we are in a @javascript session all contents would be displayed as HTML characters. 124 if ($this->running_javascript()) { 125 $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES); 126 } 127 128 } else { 129 $exceptiontype = $selector; 130 $exceptionlocator = $locator; 131 } 132 133 $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator); 134 } 135 136 // How much we will be waiting for the element to appear. 137 if (!$timeout) { 138 $timeout = self::get_timeout(); 139 $microsleep = false; 140 } else { 141 // Spinning each 0.1 seconds if the timeout was forced as we understand 142 // that is a special case and is good to refine the performance as much 143 // as possible. 144 $microsleep = true; 145 } 146 147 // Normalise the values in order to perform the search. 148 [ 149 'selector' => $selector, 150 'locator' => $locator, 151 'container' => $container, 152 ] = $this->normalise_selector($selector, $locator, $container ?: $this->getSession()->getPage()); 153 154 // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception. 155 return $this->spin( 156 function() use ($selector, $locator, $container) { 157 return $container->findAll($selector, $locator); 158 }, [], $timeout, $exception, $microsleep 159 ); 160 } 161 162 /** 163 * Normalise the locator and selector. 164 * 165 * @param string $selector The type of thing to search 166 * @param mixed $locator The locator value. Can be an array, but is more likely a string. 167 * @param Element $container An optional container to search within 168 * @return array The selector, locator, and container to search within 169 */ 170 public function normalise_selector(string $selector, $locator, Element $container): array { 171 // Check for specific transformations for this selector type. 172 $transformfunction = "transform_find_for_{$selector}"; 173 if (method_exists('behat_selectors', $transformfunction)) { 174 // A selector-specific transformation exists. 175 // Perform initial transformation of the selector within the current container. 176 [ 177 'selector' => $selector, 178 'locator' => $locator, 179 'container' => $container, 180 ] = behat_selectors::{$transformfunction}($this, $locator, $container); 181 } 182 183 // Normalise the css and xpath selector types. 184 if ('css_element' === $selector) { 185 $selector = 'css'; 186 } else if ('xpath_element' === $selector) { 187 $selector = 'xpath'; 188 } 189 190 // Convert to a named selector where the selector type is not a known selector. 191 $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector); 192 $converttonamed = $converttonamed && 'xpath' !== $selector; 193 if ($converttonamed) { 194 if (behat_partial_named_selector::is_deprecated_selector($selector)) { 195 if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) { 196 error_log("The '{$selector}' selector has been replaced with {$replacement}"); 197 $selector = $replacement; 198 } 199 } else if (behat_exact_named_selector::is_deprecated_selector($selector)) { 200 if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) { 201 error_log("The '{$selector}' selector has been replaced with {$replacement}"); 202 $selector = $replacement; 203 } 204 } 205 206 $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors(); 207 $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors(); 208 if (isset($allowedpartialselectors[$selector])) { 209 $locator = behat_selectors::normalise_named_selector($allowedpartialselectors[$selector], $locator); 210 $selector = 'named_partial'; 211 } else if (isset($allowedexactselectors[$selector])) { 212 $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator); 213 $selector = 'named_exact'; 214 } else { 215 throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver()); 216 } 217 } 218 219 return [ 220 'selector' => $selector, 221 'locator' => $locator, 222 'container' => $container, 223 ]; 224 } 225 226 /** 227 * Send key presses straight to the currently active element. 228 * 229 * The `$keys` array contains a list of key values to send to the session as defined in the WebDriver and JsonWire 230 * specifications: 231 * - JsonWire: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#sessionsessionidkeys 232 * - W3C WebDriver: https://www.w3.org/TR/webdriver/#keyboard-actions 233 * 234 * This may be a combination of typable characters, modifier keys, and other supported keypoints. 235 * 236 * The NULL_KEY should be used to release modifier keys. If the NULL_KEY is not used then modifier keys will remain 237 * in the pressed state. 238 * 239 * Example usage: 240 * 241 * behat_base::type_keys($this->getSession(), [behat_keys::SHIFT, behat_keys::TAB, behat_keys::NULL_KEY]); 242 * behat_base::type_keys($this->getSession(), [behat_keys::ENTER, behat_keys::NULL_KEY]); 243 * behat_base::type_keys($this->getSession(), [behat_keys::ESCAPE, behat_keys::NULL_KEY]); 244 * 245 * It can also be used to send text input, for example: 246 * 247 * behat_base::type_keys( 248 * $this->getSession(), 249 * ['D', 'o', ' ', 'y', 'o', 'u', ' ', 'p', 'l', 'a' 'y', ' ', 'G', 'o', '?', behat_base::NULL_KEY] 250 * ); 251 * 252 * 253 * Please note: This function does not use the element/sendKeys variants but sends keys straight to the browser. 254 * 255 * @param Session $session 256 * @param string[] $keys 257 */ 258 public static function type_keys(Session $session, array $keys): void { 259 $session->getDriver()->getWebDriver()->getKeyboard()->sendKeys($keys); 260 } 261 262 /** 263 * Finds DOM nodes in the page using named selectors. 264 * 265 * The point of using this method instead of Mink ones is the spin 266 * method of behat_base::find() that looks for the element until it 267 * is available or it timeouts, this avoids the false failures received 268 * when selenium tries to execute commands on elements that are not 269 * ready to be used. 270 * 271 * All steps that requires elements to be available before interact with 272 * them should use one of the find* methods. 273 * 274 * The methods calls requires a {'find_' . $elementtype}($locator) 275 * format, like find_link($locator), find_select($locator), 276 * find_button($locator)... 277 * 278 * @link http://mink.behat.org/#named-selectors 279 * @throws coding_exception 280 * @param string $name The name of the called method 281 * @param mixed $arguments 282 * @return NodeElement 283 */ 284 public function __call($name, $arguments) { 285 if (substr($name, 0, 5) === 'find_') { 286 return call_user_func_array([$this, 'find'], array_merge( 287 [substr($name, 5)], 288 $arguments 289 )); 290 } 291 292 throw new coding_exception("The '{$name}' method does not exist"); 293 } 294 295 /** 296 * Escapes the double quote character. 297 * 298 * Double quote is the argument delimiter, it can be escaped 299 * with a backslash, but we auto-remove this backslashes 300 * before the step execution, this method is useful when using 301 * arguments as arguments for other steps. 302 * 303 * @param string $string 304 * @return string 305 */ 306 public function escape($string) { 307 return str_replace('"', '\"', $string); 308 } 309 310 /** 311 * Executes the passed closure until returns true or time outs. 312 * 313 * In most cases the document.readyState === 'complete' will be enough, but sometimes JS 314 * requires more time to be completely loaded or an element to be visible or whatever is required to 315 * perform some action on an element; this method receives a closure which should contain the 316 * required statements to ensure the step definition actions and assertions have all their needs 317 * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the 318 * closure to the caller. 319 * 320 * The closures requirements to work well with this spin method are: 321 * - Must return false, null or '' if something goes wrong 322 * - Must return something != false if finishes as expected, this will be the (mixed) value 323 * returned by spin() 324 * 325 * The arguments of the closure are mixed, use $args depending on your needs. 326 * 327 * You can provide an exception to give more accurate feedback to tests writers, otherwise the 328 * closure exception will be used, but you must provide an exception if the closure does not throw 329 * an exception. 330 * 331 * @throws Exception If it timeouts without receiving something != false from the closure 332 * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method) 333 * @param mixed $args Arguments to pass to the closure 334 * @param int $timeout Timeout in seconds 335 * @param Exception $exception The exception to throw in case it time outs. 336 * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds. 337 * @return mixed The value returned by the closure 338 */ 339 protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) { 340 341 // Using default timeout which is pretty high. 342 if (!$timeout) { 343 $timeout = self::get_timeout(); 344 } 345 346 $start = microtime(true); 347 $end = $start + $timeout; 348 349 do { 350 // We catch the exception thrown by the step definition to execute it again. 351 try { 352 // We don't check with !== because most of the time closures will return 353 // direct Behat methods returns and we are not sure it will be always (bool)false 354 // if it just runs the behat method without returning anything $return == null. 355 if ($return = call_user_func($lambda, $this, $args)) { 356 return $return; 357 } 358 } catch (Exception $e) { 359 // We would use the first closure exception if no exception has been provided. 360 if (!$exception) { 361 $exception = $e; 362 } 363 } 364 365 if (!$this->running_javascript()) { 366 break; 367 } 368 369 usleep(100000); 370 371 } while (microtime(true) < $end); 372 373 // Using coding_exception as is a development issue if no exception has been provided. 374 if (!$exception) { 375 $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception'); 376 } 377 378 // Throwing exception to the user. 379 throw $exception; 380 } 381 382 /** 383 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. 384 * 385 * Use behat_base::get_text_selector_node() for text-based selectors. 386 * 387 * @throws ElementNotFoundException Thrown by behat_base::find 388 * @param string $selectortype 389 * @param string $element 390 * @return NodeElement 391 */ 392 protected function get_selected_node($selectortype, $element) { 393 return $this->find($selectortype, $element); 394 } 395 396 /** 397 * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. 398 * 399 * @throws ElementNotFoundException Thrown by behat_base::find 400 * @param string $selectortype 401 * @param string $element 402 * @return NodeElement 403 */ 404 protected function get_text_selector_node($selectortype, $element) { 405 // Getting Mink selector and locator. 406 list($selector, $locator) = $this->transform_text_selector($selectortype, $element); 407 408 // Returns the NodeElement. 409 return $this->find($selector, $locator); 410 } 411 412 /** 413 * Gets the requested element inside the specified container. 414 * 415 * @throws ElementNotFoundException Thrown by behat_base::find 416 * @param mixed $selectortype The element selector type. 417 * @param mixed $element The element locator. 418 * @param mixed $containerselectortype The container selector type. 419 * @param mixed $containerelement The container locator. 420 * @return NodeElement 421 */ 422 protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) { 423 if ($containerselectortype === 'NodeElement' && is_a($containerelement, NodeElement::class)) { 424 // Support a NodeElement being passed in for use in step chaining. 425 $containernode = $containerelement; 426 $locatorexceptionmsg = $element; 427 } else { 428 // Gets the container, it will always be text based. 429 $containernode = $this->get_text_selector_node($containerselectortype, $containerelement); 430 $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"'; 431 } 432 433 $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg); 434 435 return $this->find($selectortype, $element, $exception, $containernode); 436 } 437 438 /** 439 * Transforms from step definition's argument style to Mink format. 440 * 441 * Mink has 3 different selectors css, xpath and named, where named 442 * selectors includes link, button, field... to simplify and group multiple 443 * steps in one we use the same interface, considering all link, buttons... 444 * at the same level as css selectors and xpath; this method makes the 445 * conversion from the arguments received by the steps to the selectors and locators 446 * required to interact with Mink. 447 * 448 * @throws ExpectationException 449 * @param string $selectortype It can be css, xpath or any of the named selectors. 450 * @param string $element The locator (or string) we are looking for. 451 * @return array Contains the selector and the locator expected by Mink. 452 */ 453 protected function transform_selector($selectortype, $element) { 454 // Here we don't know if an allowed text selector is being used. 455 $selectors = behat_selectors::get_allowed_selectors(); 456 if (!isset($selectors[$selectortype])) { 457 throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession()); 458 } 459 460 [ 461 'selector' => $selector, 462 'locator' => $locator, 463 ] = $this->normalise_selector($selectortype, $element, $this->getSession()->getPage()); 464 465 return [$selector, $locator]; 466 } 467 468 /** 469 * Transforms from step definition's argument style to Mink format. 470 * 471 * Delegates all the process to behat_base::transform_selector() checking 472 * the provided $selectortype. 473 * 474 * @throws ExpectationException 475 * @param string $selectortype It can be css, xpath or any of the named selectors. 476 * @param string $element The locator (or string) we are looking for. 477 * @return array Contains the selector and the locator expected by Mink. 478 */ 479 protected function transform_text_selector($selectortype, $element) { 480 481 $selectors = behat_selectors::get_allowed_text_selectors(); 482 if (empty($selectors[$selectortype])) { 483 throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession()); 484 } 485 486 return $this->transform_selector($selectortype, $element); 487 } 488 489 /** 490 * Whether Javascript is available in the current Session. 491 * 492 * @return boolean 493 */ 494 protected function running_javascript() { 495 return self::running_javascript_in_session($this->getSession()); 496 } 497 498 /** 499 * Require that javascript be available in the current Session. 500 * 501 * @throws DriverException 502 */ 503 protected function require_javascript() { 504 return self::require_javascript_in_session($this->getSession()); 505 } 506 507 /** 508 * Whether Javascript is available in the specified Session. 509 * 510 * @param Session $session 511 * @return boolean 512 */ 513 protected static function running_javascript_in_session(Session $session): bool { 514 return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver'; 515 } 516 517 /** 518 * Require that javascript be available for the specified Session. 519 * 520 * @param Session $session 521 * @throws DriverException 522 */ 523 protected static function require_javascript_in_session(Session $session): void { 524 if (self::running_javascript_in_session($session)) { 525 return; 526 } 527 528 throw new DriverException('Javascript is required'); 529 } 530 531 /** 532 * Checks if the current page is part of the mobile app. 533 * 534 * @return bool True if it's in the app 535 */ 536 protected function is_in_app() : bool { 537 // Cannot be in the app if there's no @app tag on scenario. 538 if (!$this->has_tag('app')) { 539 return false; 540 } 541 542 // Check on page to see if it's an app page. Safest way is to look for added JavaScript. 543 return $this->evaluate_script('return typeof window.behat') === 'object'; 544 } 545 546 /** 547 * Spins around an element until it exists 548 * 549 * @throws ExpectationException 550 * @param string $locator 551 * @param string $selectortype 552 * @return void 553 */ 554 protected function ensure_element_exists($locator, $selectortype) { 555 // Exception if it timesout and the element is still there. 556 $msg = "The '{$locator}' element does not exist and should"; 557 $exception = new ExpectationException($msg, $this->getSession()); 558 559 // Normalise the values in order to perform the search. 560 [ 561 'selector' => $selector, 562 'locator' => $locator, 563 'container' => $container, 564 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 565 566 // It will stop spinning once the find() method returns true. 567 $this->spin( 568 function() use ($selector, $locator, $container) { 569 if ($container->find($selector, $locator)) { 570 return true; 571 } 572 return false; 573 }, 574 [], 575 self::get_extended_timeout(), 576 $exception, 577 true 578 ); 579 } 580 581 /** 582 * Spins until the element does not exist 583 * 584 * @throws ExpectationException 585 * @param string $locator 586 * @param string $selectortype 587 * @return void 588 */ 589 protected function ensure_element_does_not_exist($locator, $selectortype) { 590 // Exception if it timesout and the element is still there. 591 $msg = "The '{$locator}' element exists and should not exist"; 592 $exception = new ExpectationException($msg, $this->getSession()); 593 594 // Normalise the values in order to perform the search. 595 [ 596 'selector' => $selector, 597 'locator' => $locator, 598 'container' => $container, 599 ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage()); 600 601 // It will stop spinning once the find() method returns false. 602 $this->spin( 603 function() use ($selector, $locator, $container) { 604 if ($container->find($selector, $locator)) { 605 return false; 606 } 607 return true; 608 }, 609 // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin(). 610 // We cannot nest spins because the outer spin times out before the inner spin completes. 611 [], 612 self::get_extended_timeout(), 613 $exception, 614 true 615 ); 616 } 617 618 /** 619 * Ensures that the provided node is visible and we can interact with it. 620 * 621 * @throws ExpectationException 622 * @param NodeElement $node 623 * @return void Throws an exception if it times out without the element being visible 624 */ 625 protected function ensure_node_is_visible($node) { 626 627 if (!$this->running_javascript()) { 628 return; 629 } 630 631 // Exception if it timesout and the element is still there. 632 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 633 $exception = new ExpectationException($msg, $this->getSession()); 634 635 // It will stop spinning once the isVisible() method returns true. 636 $this->spin( 637 function($context, $args) { 638 if ($args->isVisible()) { 639 return true; 640 } 641 return false; 642 }, 643 $node, 644 self::get_extended_timeout(), 645 $exception, 646 true 647 ); 648 } 649 650 /** 651 * Ensures that the provided node has a attribute value set. This step can be used to check if specific 652 * JS has finished modifying the node. 653 * 654 * @throws ExpectationException 655 * @param NodeElement $node 656 * @param string $attribute attribute name 657 * @param string $attributevalue attribute value to check. 658 * @return void Throws an exception if it times out without the element being visible 659 */ 660 protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) { 661 662 if (!$this->running_javascript()) { 663 return; 664 } 665 666 // Exception if it timesout and the element is still there. 667 $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; 668 $exception = new ExpectationException($msg, $this->getSession()); 669 670 // It will stop spinning once the $args[1]) == $args[2], and method returns true. 671 $this->spin( 672 function($context, $args) { 673 if ($args[0]->getAttribute($args[1]) == $args[2]) { 674 return true; 675 } 676 return false; 677 }, 678 array($node, $attribute, $attributevalue), 679 self::get_extended_timeout(), 680 $exception, 681 true 682 ); 683 } 684 685 /** 686 * Ensures that the provided element is visible and we can interact with it. 687 * 688 * Returns the node in case other actions are interested in using it. 689 * 690 * @throws ExpectationException 691 * @param string $element 692 * @param string $selectortype 693 * @return NodeElement Throws an exception if it times out without being visible 694 */ 695 protected function ensure_element_is_visible($element, $selectortype) { 696 697 if (!$this->running_javascript()) { 698 return; 699 } 700 701 $node = $this->get_selected_node($selectortype, $element); 702 $this->ensure_node_is_visible($node); 703 704 return $node; 705 } 706 707 /** 708 * Ensures that all the page's editors are loaded. 709 * 710 * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more. 711 * @throws ElementNotFoundException 712 * @throws ExpectationException 713 * @return void 714 */ 715 protected function ensure_editors_are_loaded() { 716 global $CFG; 717 718 if (empty($CFG->behat_usedeprecated)) { 719 debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.'); 720 } 721 return; 722 } 723 724 /** 725 * Checks if the current scenario, or its feature, has a specified tag. 726 * 727 * @param string $tag Tag to check 728 * @return bool True if the tag exists in scenario or feature 729 */ 730 public function has_tag(string $tag) : bool { 731 return array_key_exists($tag, behat_hooks::get_tags_for_scenario()); 732 } 733 734 /** 735 * Change browser window size. 736 * - small: 640x480 737 * - medium: 1024x768 738 * - large: 2560x1600 739 * 740 * @param string $windowsize size of window. 741 * @param bool $viewport If true, changes viewport rather than window size 742 * @throws ExpectationException 743 */ 744 protected function resize_window($windowsize, $viewport = false) { 745 global $CFG; 746 747 // Non JS don't support resize window. 748 if (!$this->running_javascript()) { 749 return; 750 } 751 752 switch ($windowsize) { 753 case "small": 754 $width = 1024; 755 $height = 768; 756 break; 757 case "medium": 758 $width = 1366; 759 $height = 768; 760 break; 761 case "large": 762 $width = 2560; 763 $height = 1600; 764 break; 765 default: 766 preg_match('/^(\d+x\d+)$/', $windowsize, $matches); 767 if (empty($matches) || (count($matches) != 2)) { 768 throw new ExpectationException("Invalid screen size, can't resize", $this->getSession()); 769 } 770 $size = explode('x', $windowsize); 771 $width = (int) $size[0]; 772 $height = (int) $size[1]; 773 } 774 775 if (isset($CFG->behat_window_size_modifier) && is_numeric($CFG->behat_window_size_modifier)) { 776 $width *= $CFG->behat_window_size_modifier; 777 $height *= $CFG->behat_window_size_modifier; 778 } 779 780 if ($viewport) { 781 // When setting viewport size, we set it so that the document width will be exactly 782 // as specified, assuming that there is a vertical scrollbar. (In cases where there is 783 // no scrollbar it will be slightly wider. We presume this is rare and predictable.) 784 // The window inner height will be as specified, which means the available viewport will 785 // actually be smaller if there is a horizontal scrollbar. We assume that horizontal 786 // scrollbars are rare so this doesn't matter. 787 $js = <<<EOF 788 return (function() { 789 var before = document.body.style.overflowY; 790 document.body.style.overflowY = "scroll"; 791 var result = {}; 792 result.x = window.outerWidth - document.body.offsetWidth; 793 result.y = window.outerHeight - window.innerHeight; 794 document.body.style.overflowY = before; 795 return result; 796 })(); 797 EOF; 798 $offset = $this->evaluate_script($js); 799 $width += $offset['x']; 800 $height += $offset['y']; 801 } 802 803 $this->getSession()->getDriver()->resizeWindow($width, $height); 804 } 805 806 /** 807 * Waits for all the JS to be loaded. 808 * 809 * @return bool Whether any JS is still pending completion. 810 */ 811 public function wait_for_pending_js() { 812 return static::wait_for_pending_js_in_session($this->getSession()); 813 } 814 815 /** 816 * Waits for all the JS to be loaded. 817 * 818 * @param Session $session The Mink Session where JS can be run 819 * @return bool Whether any JS is still pending completion. 820 */ 821 public static function wait_for_pending_js_in_session(Session $session) { 822 if (!self::running_javascript_in_session($session)) { 823 // JS is not available therefore there is nothing to wait for. 824 return false; 825 } 826 827 // We don't use behat_base::spin() here as we don't want to end up with an exception 828 // if the page & JSs don't finish loading properly. 829 for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) { 830 $pending = ''; 831 try { 832 $jscode = trim(preg_replace('/\s+/', ' ', ' 833 return (function() { 834 if (document.readyState !== "complete") { 835 return "incomplete"; 836 } 837 838 if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") { 839 return ""; 840 } 841 842 return M.util.pending_js.join(":"); 843 })()')); 844 $pending = self::evaluate_script_in_session($session, $jscode); 845 } catch (NoSuchWindowException $nsw) { 846 // We catch an exception here, in case we just closed the window we were interacting with. 847 // No javascript is running if there is no window right? 848 $pending = ''; 849 } catch (UnknownError $e) { 850 // M is not defined when the window or the frame don't exist anymore. 851 if (strstr($e->getMessage(), 'M is not defined') != false) { 852 $pending = ''; 853 } 854 } 855 856 // If there are no pending JS we stop waiting. 857 if ($pending === '') { 858 return true; 859 } 860 861 // 0.1 seconds. 862 usleep(100000); 863 } 864 865 // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions(). 866 // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds 867 // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the 868 // number of JS pending code and JS completed code will not match and we will reach this point. 869 throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . 870 self::get_extended_timeout() . 871 ' seconds. There is a Javascript error or the code is extremely slow (' . $pending . 872 '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.'); 873 } 874 875 /** 876 * Internal step definition to find exceptions, debugging() messages and PHP debug messages. 877 * 878 * Part of behat_hooks class as is part of the testing framework, is auto-executed 879 * after each step so no features will splicitly use it. 880 * 881 * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception. 882 * @see Moodle\BehatExtension\Tester\MoodleStepTester 883 */ 884 public function look_for_exceptions() { 885 // Wrap in try in case we were interacting with a closed window. 886 try { 887 888 // Exceptions. 889 $exceptionsxpath = "//div[@data-rel='fatalerror']"; 890 // Debugging messages. 891 $debuggingxpath = "//div[@data-rel='debugging']"; 892 // PHP debug messages. 893 $phperrorxpath = "//div[@data-rel='phpdebugmessage']"; 894 // Any other backtrace. 895 $othersxpath = "(//*[contains(., ': call to ')])[1]"; 896 897 $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath); 898 $joinedxpath = implode(' | ', $xpaths); 899 900 // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check 901 // is faster than to send the 4 xpath queries for each step. 902 if (!$this->getSession()->getDriver()->find($joinedxpath)) { 903 // Check if we have recorded any errors in driver process. 904 $phperrors = behat_get_shutdown_process_errors(); 905 if (!empty($phperrors)) { 906 foreach ($phperrors as $error) { 907 $errnostring = behat_get_error_string($error['type']); 908 $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line']; 909 } 910 $msg = "PHP errors found:\n" . implode("\n", $msgs); 911 throw new \Exception(htmlentities($msg)); 912 } 913 914 return; 915 } 916 917 // Exceptions. 918 if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) { 919 920 // Getting the debugging info and the backtrace. 921 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error'); 922 // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class. 923 if (empty($errorinfoboxes)) { 924 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger'); 925 } 926 // If errorinfoboxes is empty, try find notifytiny (original) class. 927 if (empty($errorinfoboxes)) { 928 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny'); 929 } 930 931 // If errorinfoboxes is empty, try find ajax/JS exception in dialogue. 932 if (empty($errorinfoboxes)) { 933 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message'); 934 935 // If ajax/JS exception. 936 if ($errorinfoboxes) { 937 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()); 938 } 939 940 } else { 941 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" . 942 $this->get_debug_text($errorinfoboxes[1]->getHtml()); 943 } 944 945 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo; 946 throw new \Exception(html_entity_decode($msg)); 947 } 948 949 // Debugging messages. 950 if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) { 951 $msgs = array(); 952 foreach ($debuggingmessages as $debuggingmessage) { 953 $msgs[] = $this->get_debug_text($debuggingmessage->getHtml()); 954 } 955 $msg = "debugging() message/s found:\n" . implode("\n", $msgs); 956 throw new \Exception(html_entity_decode($msg)); 957 } 958 959 // PHP debug messages. 960 if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) { 961 962 $msgs = array(); 963 foreach ($phpmessages as $phpmessage) { 964 $msgs[] = $this->get_debug_text($phpmessage->getHtml()); 965 } 966 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs); 967 throw new \Exception(html_entity_decode($msg)); 968 } 969 970 // Any other backtrace. 971 // First looking through xpath as it is faster than get and parse the whole page contents, 972 // we get the contents and look for matches once we found something to suspect that there is a backtrace. 973 if ($this->getSession()->getDriver()->find($othersxpath)) { 974 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/'; 975 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) { 976 $msgs = array(); 977 foreach ($backtraces[0] as $backtrace) { 978 $msgs[] = $backtrace . '()'; 979 } 980 $msg = "Other backtraces found:\n" . implode("\n", $msgs); 981 throw new \Exception(htmlentities($msg)); 982 } 983 } 984 985 } catch (NoSuchWindowException $e) { 986 // If we were interacting with a popup window it will not exists after closing it. 987 } catch (DriverException $e) { 988 // Same reason as above. 989 } 990 } 991 992 /** 993 * Converts HTML tags to line breaks to display the info in CLI 994 * 995 * @param string $html 996 * @return string 997 */ 998 protected function get_debug_text($html) { 999 1000 // Replacing HTML tags for new lines and keeping only the text. 1001 $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html); 1002 return preg_replace("/(\n)+/s", "\n", $notags); 1003 } 1004 1005 /** 1006 * Helper function to execute api in a given context. 1007 * 1008 * @param string $contextapi context in which api is defined. 1009 * @param array $params list of params to pass. 1010 * @throws Exception 1011 */ 1012 protected function execute($contextapi, $params = array()) { 1013 if (!is_array($params)) { 1014 $params = array($params); 1015 } 1016 1017 // Get required context and execute the api. 1018 $contextapi = explode("::", $contextapi); 1019 $context = behat_context_helper::get($contextapi[0]); 1020 call_user_func_array(array($context, $contextapi[1]), $params); 1021 1022 // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results. 1023 // Don't make them optional for performance reasons. 1024 1025 // Wait for pending js. 1026 $this->wait_for_pending_js(); 1027 1028 // Look for exceptions. 1029 $this->look_for_exceptions(); 1030 } 1031 1032 /** 1033 * Get the actual user in the behat session (note $USER does not correspond to the behat session's user). 1034 * @return mixed 1035 * @throws coding_exception 1036 */ 1037 protected function get_session_user() { 1038 global $DB; 1039 1040 $sid = $this->getSession()->getCookie('MoodleSession'); 1041 if (empty($sid)) { 1042 throw new coding_exception('failed to get moodle session'); 1043 } 1044 $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]); 1045 if (empty($userid)) { 1046 throw new coding_exception('failed to get user from seession id '.$sid); 1047 } 1048 return $DB->get_record('user', ['id' => $userid]); 1049 } 1050 1051 /** 1052 * Set current $USER, reset access cache. 1053 * 1054 * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some 1055 * API's might rely on the logged user to take some action. 1056 * 1057 * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid 1058 */ 1059 public static function set_user($user = null) { 1060 global $DB; 1061 1062 if (is_object($user)) { 1063 $user = clone($user); 1064 } else if (!$user) { 1065 // Assign valid data to admin user (some generator-related code needs a valid user). 1066 $user = $DB->get_record('user', array('username' => 'admin')); 1067 } else { 1068 $user = $DB->get_record('user', array('id' => $user)); 1069 } 1070 unset($user->description); 1071 unset($user->access); 1072 unset($user->preference); 1073 1074 // Ensure session is empty, as it may contain caches and user specific info. 1075 \core\session\manager::init_empty_session(); 1076 1077 \core\session\manager::set_user($user); 1078 } 1079 1080 /** 1081 * Gets the internal moodle context id from the context reference. 1082 * 1083 * The context reference changes depending on the context 1084 * level, it can be the system, a user, a category, a course or 1085 * a module. 1086 * 1087 * @throws Exception 1088 * @param string $levelname The context level string introduced by the test writer 1089 * @param string $contextref The context reference introduced by the test writer 1090 * @return context 1091 */ 1092 public static function get_context(string $levelname, string $contextref): context { 1093 global $DB; 1094 1095 // Getting context levels and names (we will be using the English ones as it is the test site language). 1096 $contextlevels = context_helper::get_all_levels(); 1097 $contextnames = array(); 1098 foreach ($contextlevels as $level => $classname) { 1099 $contextnames[context_helper::get_level_name($level)] = $level; 1100 } 1101 1102 if (empty($contextnames[$levelname])) { 1103 throw new Exception('The specified "' . $levelname . '" context level does not exist'); 1104 } 1105 $contextlevel = $contextnames[$levelname]; 1106 1107 // Return it, we don't need to look for other internal ids. 1108 if ($contextlevel == CONTEXT_SYSTEM) { 1109 return context_system::instance(); 1110 } 1111 1112 switch ($contextlevel) { 1113 1114 case CONTEXT_USER: 1115 $instanceid = $DB->get_field('user', 'id', array('username' => $contextref)); 1116 break; 1117 1118 case CONTEXT_COURSECAT: 1119 $instanceid = $DB->get_field('course_categories', 'id', array('idnumber' => $contextref)); 1120 break; 1121 1122 case CONTEXT_COURSE: 1123 $instanceid = $DB->get_field('course', 'id', array('shortname' => $contextref)); 1124 break; 1125 1126 case CONTEXT_MODULE: 1127 $instanceid = $DB->get_field('course_modules', 'id', array('idnumber' => $contextref)); 1128 break; 1129 1130 default: 1131 break; 1132 } 1133 1134 $contextclass = $contextlevels[$contextlevel]; 1135 if (!$context = $contextclass::instance($instanceid, IGNORE_MISSING)) { 1136 throw new Exception('The specified "' . $contextref . '" context reference does not exist'); 1137 } 1138 1139 return $context; 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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body