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