Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * General use steps definitions.
  19   *
  20   * @package   core
  21   * @category  test
  22   * @copyright 2012 David MonllaĆ³
  23   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
  27  
  28  require_once (__DIR__ . '/../../behat/behat_base.php');
  29  
  30  use Behat\Gherkin\Node\TableNode;
  31  use Behat\Mink\Element\NodeElement;
  32  use Behat\Mink\Exception\DriverException;
  33  use Behat\Mink\Exception\ElementNotFoundException;
  34  use Behat\Mink\Exception\ExpectationException;
  35  use Facebook\WebDriver\Exception\NoSuchElementException;
  36  use Facebook\WebDriver\Exception\StaleElementReferenceException;
  37  
  38  /**
  39   * Cross component steps definitions.
  40   *
  41   * Basic web application definitions from MinkExtension and
  42   * BehatchExtension. Definitions modified according to our needs
  43   * when necessary and including only the ones we need to avoid
  44   * overlapping and confusion.
  45   *
  46   * @package   core
  47   * @category  test
  48   * @copyright 2012 David MonllaĆ³
  49   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  50   */
  51  class behat_general extends behat_base {
  52  
  53      /**
  54       * @var string used by {@link switch_to_window()} and
  55       * {@link switch_to_the_main_window()} to work-around a Chrome browser issue.
  56       */
  57      const MAIN_WINDOW_NAME = '__moodle_behat_main_window_name';
  58  
  59      /**
  60       * @var string when we want to check whether or not a new page has loaded,
  61       * we first write this unique string into the page. Then later, by checking
  62       * whether it is still there, we can tell if a new page has been loaded.
  63       */
  64      const PAGE_LOAD_DETECTION_STRING = 'new_page_not_loaded_since_behat_started_watching';
  65  
  66      /**
  67       * @var $pageloaddetectionrunning boolean Used to ensure that page load detection was started before a page reload
  68       * was checked for.
  69       */
  70      private $pageloaddetectionrunning = false;
  71  
  72      /**
  73       * Opens Moodle homepage.
  74       *
  75       * @Given /^I am on homepage$/
  76       */
  77      public function i_am_on_homepage() {
  78          $this->execute('behat_general::i_visit', ['/']);
  79      }
  80  
  81      /**
  82       * Opens Moodle site homepage.
  83       *
  84       * @Given /^I am on site homepage$/
  85       */
  86      public function i_am_on_site_homepage() {
  87          $this->execute('behat_general::i_visit', ['/?redirect=0']);
  88      }
  89  
  90      /**
  91       * Opens course index page.
  92       *
  93       * @Given /^I am on course index$/
  94       */
  95      public function i_am_on_course_index() {
  96          $this->execute('behat_general::i_visit', ['/course/index.php']);
  97      }
  98  
  99      /**
 100       * Reloads the current page.
 101       *
 102       * @Given /^I reload the page$/
 103       */
 104      public function reload() {
 105          $this->getSession()->reload();
 106      }
 107  
 108      /**
 109       * Follows the page redirection. Use this step after any action that shows a message and waits for a redirection
 110       *
 111       * @Given /^I wait to be redirected$/
 112       */
 113      public function i_wait_to_be_redirected() {
 114  
 115          // Xpath and processes based on core_renderer::redirect_message(), core_renderer::$metarefreshtag and
 116          // moodle_page::$periodicrefreshdelay possible values.
 117          if (!$metarefresh = $this->getSession()->getPage()->find('xpath', "//head/descendant::meta[@http-equiv='refresh']")) {
 118              // We don't fail the scenario if no redirection with message is found to avoid race condition false failures.
 119              return true;
 120          }
 121  
 122          // Wrapped in try & catch in case the redirection has already been executed.
 123          try {
 124              $content = $metarefresh->getAttribute('content');
 125          } catch (NoSuchElementException $e) {
 126              return true;
 127          } catch (StaleElementReferenceException $e) {
 128              return true;
 129          }
 130  
 131          // Getting the refresh time and the url if present.
 132          if (strstr($content, 'url') != false) {
 133  
 134              list($waittime, $url) = explode(';', $content);
 135  
 136              // Cleaning the URL value.
 137              $url = trim(substr($url, strpos($url, 'http')));
 138  
 139          } else {
 140              // Just wait then.
 141              $waittime = $content;
 142          }
 143  
 144  
 145          // Wait until the URL change is executed.
 146          if ($this->running_javascript()) {
 147              $this->getSession()->wait($waittime * 1000);
 148  
 149          } else if (!empty($url)) {
 150              // We redirect directly as we can not wait for an automatic redirection.
 151              $this->getSession()->getDriver()->getClient()->request('GET', $url);
 152  
 153          } else {
 154              // Reload the page if no URL was provided.
 155              $this->getSession()->getDriver()->reload();
 156          }
 157      }
 158  
 159      /**
 160       * Switches to the specified iframe.
 161       *
 162       * @Given /^I switch to "(?P<iframe_name_string>(?:[^"]|\\")*)" iframe$/
 163       * @Given /^I switch to "(?P<iframe_name_string>(?:[^"]|\\")*)" class iframe$/
 164       * @param string $name The name of the iframe
 165       */
 166      public function switch_to_iframe($name) {
 167          // We spin to give time to the iframe to be loaded.
 168          // Using extended timeout as we don't know about which
 169          // kind of iframe will be loaded.
 170          $this->spin(
 171              function($context) use ($name){
 172                  $iframe = $context->find('iframe', $name);
 173                  if ($iframe->hasAttribute('name')) {
 174                      $iframename = $iframe->getAttribute('name');
 175                  } else {
 176                      if (!$this->running_javascript()) {
 177                          throw new \coding_exception('iframe must have a name attribute to use the switchTo command.');
 178                      }
 179                      $iframename = uniqid();
 180                      $this->execute_js_on_node($iframe, "{{ELEMENT}}.name = '{$iframename}';");
 181                  }
 182                  $context->getSession()->switchToIFrame($iframename);
 183  
 184                  // If no exception we are done.
 185                  return true;
 186              },
 187              behat_base::get_extended_timeout()
 188          );
 189      }
 190  
 191      /**
 192       * Switches to the main Moodle frame.
 193       *
 194       * @Given /^I switch to the main frame$/
 195       */
 196      public function switch_to_the_main_frame() {
 197          $this->getSession()->switchToIFrame();
 198      }
 199  
 200      /**
 201       * Switches to the specified window. Useful when interacting with popup windows.
 202       *
 203       * @Given /^I switch to "(?P<window_name_string>(?:[^"]|\\")*)" (window|tab)$/
 204       * @param string $windowname
 205       */
 206      public function switch_to_window($windowname) {
 207          if ($windowname === self::MAIN_WINDOW_NAME) {
 208              // When switching to the main window normalise the window name to null.
 209              // This is normalised further in the Mink driver to the root window ID.
 210              $windowname = null;
 211          }
 212  
 213          $this->getSession()->switchToWindow($windowname);
 214      }
 215  
 216      /**
 217       * Switches to a second window.
 218       *
 219       * @Given /^I switch to a second window$/
 220       * @throws DriverException If there aren't exactly 2 windows open.
 221       */
 222      public function switch_to_second_window() {
 223          $names = $this->getSession()->getWindowNames();
 224  
 225          if (count($names) !== 2) {
 226              throw new DriverException('Expected to see 2 windows open, found ' . count($names));
 227          }
 228  
 229          $this->getSession()->switchToWindow($names[1]);
 230      }
 231  
 232      /**
 233       * Switches to the main Moodle window. Useful when you finish interacting with popup windows.
 234       *
 235       * @Given /^I switch to the main (window|tab)$/
 236       */
 237      public function switch_to_the_main_window() {
 238          $this->switch_to_window(self::MAIN_WINDOW_NAME);
 239      }
 240  
 241      /**
 242       * Closes all extra windows opened during the navigation.
 243       *
 244       * This assumes all popups are opened by the main tab and you will now get back.
 245       *
 246       * @Given /^I close all opened windows$/
 247       * @throws DriverException If there aren't exactly 1 tabs open when finish or no javascript running
 248       */
 249      public function i_close_all_opened_windows() {
 250          if (!$this->running_javascript()) {
 251              throw new DriverException('Closing windows steps require javascript');
 252          }
 253          $names = $this->getSession()->getWindowNames();
 254          for ($index = 1; $index < count($names); $index ++) {
 255              $this->getSession()->switchToWindow($names[$index]);
 256              $this->execute_script("window.open('', '_self').close();");
 257          }
 258          $names = $this->getSession()->getWindowNames();
 259          if (count($names) !== 1) {
 260              throw new DriverException('Expected to see 1 tabs open, not ' . count($names));
 261          }
 262          $this->getSession()->switchToWindow($names[0]);
 263      }
 264  
 265      /**
 266       * Accepts the currently displayed alert dialog. This step does not work in all the browsers, consider it experimental.
 267       * @Given /^I accept the currently displayed dialog$/
 268       */
 269      public function accept_currently_displayed_alert_dialog() {
 270          $this->getSession()->getDriver()->getWebDriver()->switchTo()->alert()->accept();
 271      }
 272  
 273      /**
 274       * Dismisses the currently displayed alert dialog. This step does not work in all the browsers, consider it experimental.
 275       * @Given /^I dismiss the currently displayed dialog$/
 276       */
 277      public function dismiss_currently_displayed_alert_dialog() {
 278          $this->getSession()->getDriver()->getWebDriver()->switchTo()->alert()->dismiss();
 279      }
 280  
 281      /**
 282       * Clicks link with specified id|title|alt|text.
 283       *
 284       * @When /^I follow "(?P<link_string>(?:[^"]|\\")*)"$/
 285       * @throws ElementNotFoundException Thrown by behat_base::find
 286       * @param string $link
 287       */
 288      public function click_link($link) {
 289  
 290          $linknode = $this->find_link($link);
 291          $this->ensure_node_is_visible($linknode);
 292          $linknode->click();
 293      }
 294  
 295      /**
 296       * Waits X seconds. Required after an action that requires data from an AJAX request.
 297       *
 298       * @Then /^I wait "(?P<seconds_number>\d+)" seconds$/
 299       * @param int $seconds
 300       */
 301      public function i_wait_seconds($seconds) {
 302          if ($this->running_javascript()) {
 303              $this->getSession()->wait($seconds * 1000);
 304          } else {
 305              sleep($seconds);
 306          }
 307      }
 308  
 309      /**
 310       * Waits until the page is completely loaded. This step is auto-executed after every step.
 311       *
 312       * @Given /^I wait until the page is ready$/
 313       */
 314      public function wait_until_the_page_is_ready() {
 315  
 316          // No need to wait if not running JS.
 317          if (!$this->running_javascript()) {
 318              return;
 319          }
 320  
 321          $this->getSession()->wait(self::get_timeout() * 1000, self::PAGE_READY_JS);
 322      }
 323  
 324      /**
 325       * Waits until the provided element selector exists in the DOM
 326       *
 327       * Using the protected method as this method will be usually
 328       * called by other methods which are not returning a set of
 329       * steps and performs the actions directly, so it would not
 330       * be executed if it returns another step.
 331  
 332       * @Given /^I wait until "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" exists$/
 333       * @param string $element
 334       * @param string $selector
 335       * @return void
 336       */
 337      public function wait_until_exists($element, $selectortype) {
 338          $this->ensure_element_exists($element, $selectortype);
 339      }
 340  
 341      /**
 342       * Waits until the provided element does not exist in the DOM
 343       *
 344       * Using the protected method as this method will be usually
 345       * called by other methods which are not returning a set of
 346       * steps and performs the actions directly, so it would not
 347       * be executed if it returns another step.
 348  
 349       * @Given /^I wait until "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" does not exist$/
 350       * @param string $element
 351       * @param string $selector
 352       * @return void
 353       */
 354      public function wait_until_does_not_exists($element, $selectortype) {
 355          $this->ensure_element_does_not_exist($element, $selectortype);
 356      }
 357  
 358      /**
 359       * Generic mouse over action. Mouse over a element of the specified type.
 360       *
 361       * @When /^I hover "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
 362       * @param string $element Element we look for
 363       * @param string $selectortype The type of what we look for
 364       */
 365      public function i_hover($element, $selectortype) {
 366          // Gets the node based on the requested selector type and locator.
 367          $node = $this->get_selected_node($selectortype, $element);
 368          $this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView();');
 369          $node->mouseOver();
 370      }
 371  
 372      /**
 373       * Generic mouse over action. Mouse over a element of the specified type.
 374       *
 375       * @When /^I hover over the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*) in the "(?P<container_element_string>(?:[^"]|\\")*)" "(?P<container_selector_string>[^"]*)"$/
 376       * @param string $element Element we look for
 377       * @param string $selectortype The type of what we look for
 378       * @param string $containerelement Element we look for
 379       * @param string $containerselectortype The type of what we look for
 380       */
 381      public function i_hover_in_the(string $element, $selectortype, string $containerelement, $containerselectortype): void {
 382          // Gets the node based on the requested selector type and locator.
 383          $node = $this->get_node_in_container($selectortype, $element, $containerselectortype, $containerselectortype);
 384          $this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView();');
 385          $node->mouseOver();
 386      }
 387  
 388      /**
 389       * Generic click action. Click on the element of the specified type.
 390       *
 391       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
 392       * @param string $element Element we look for
 393       * @param string $selectortype The type of what we look for
 394       */
 395      public function i_click_on($element, $selectortype) {
 396  
 397          // Gets the node based on the requested selector type and locator.
 398          $node = $this->get_selected_node($selectortype, $element);
 399          $this->ensure_node_is_visible($node);
 400          $node->click();
 401      }
 402  
 403      /**
 404       * Sets the focus and takes away the focus from an element, generating blur JS event.
 405       *
 406       * @When /^I take focus off "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
 407       * @param string $element Element we look for
 408       * @param string $selectortype The type of what we look for
 409       */
 410      public function i_take_focus_off_field($element, $selectortype) {
 411          if (!$this->running_javascript()) {
 412              throw new ExpectationException('Can\'t take focus off from "' . $element . '" in non-js mode', $this->getSession());
 413          }
 414          // Gets the node based on the requested selector type and locator.
 415          $node = $this->get_selected_node($selectortype, $element);
 416          $this->ensure_node_is_visible($node);
 417  
 418          // Ensure element is focused before taking it off.
 419          $node->focus();
 420          $node->blur();
 421      }
 422  
 423      /**
 424       * Clicks the specified element and confirms the expected dialogue.
 425       *
 426       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" confirming the dialogue$/
 427       * @throws ElementNotFoundException Thrown by behat_base::find
 428       * @param string $element Element we look for
 429       * @param string $selectortype The type of what we look for
 430       */
 431      public function i_click_on_confirming_the_dialogue($element, $selectortype) {
 432          $this->i_click_on($element, $selectortype);
 433          $this->execute('behat_general::accept_currently_displayed_alert_dialog', []);
 434          $this->wait_until_the_page_is_ready();
 435      }
 436  
 437      /**
 438       * Clicks the specified element and dismissing the expected dialogue.
 439       *
 440       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" dismissing the dialogue$/
 441       * @throws ElementNotFoundException Thrown by behat_base::find
 442       * @param string $element Element we look for
 443       * @param string $selectortype The type of what we look for
 444       */
 445      public function i_click_on_dismissing_the_dialogue($element, $selectortype) {
 446          $this->i_click_on($element, $selectortype);
 447          $this->execute('behat_general::dismiss_currently_displayed_alert_dialog', []);
 448          $this->wait_until_the_page_is_ready();
 449      }
 450  
 451      /**
 452       * Click on the element of the specified type which is located inside the second element.
 453       *
 454       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" in the "(?P<element_container_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
 455       * @param string $element Element we look for
 456       * @param string $selectortype The type of what we look for
 457       * @param string $nodeelement Element we look in
 458       * @param string $nodeselectortype The type of selector where we look in
 459       */
 460      public function i_click_on_in_the($element, $selectortype, $nodeelement, $nodeselectortype) {
 461  
 462          $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
 463          $this->ensure_node_is_visible($node);
 464          $node->click();
 465      }
 466  
 467      /**
 468       * Drags and drops the specified element to the specified container. This step does not work in all the browsers, consider it experimental.
 469       *
 470       * The steps definitions calling this step as part of them should
 471       * manage the wait times by themselves as the times and when the
 472       * waits should be done depends on what is being dragged & dropper.
 473       *
 474       * @Given /^I drag "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector1_string>(?:[^"]|\\")*)" and I drop it in "(?P<container_element_string>(?:[^"]|\\")*)" "(?P<selector2_string>(?:[^"]|\\")*)"$/
 475       * @param string $element
 476       * @param string $selectortype
 477       * @param string $containerelement
 478       * @param string $containerselectortype
 479       */
 480      public function i_drag_and_i_drop_it_in($source, $sourcetype, $target, $targettype) {
 481          if (!$this->running_javascript()) {
 482              throw new DriverException('Drag and drop steps require javascript');
 483          }
 484  
 485          $source = $this->find($sourcetype, $source);
 486          $target = $this->find($targettype, $target);
 487  
 488          if (!$source->isVisible()) {
 489              throw new ExpectationException("'{$source}' '{$sourcetype}' is not visible", $this->getSession());
 490          }
 491          if (!$target->isVisible()) {
 492              throw new ExpectationException("'{$target}' '{$targettype}' is not visible", $this->getSession());
 493          }
 494  
 495          $this->getSession()->getDriver()->dragTo($source->getXpath(), $target->getXpath());
 496      }
 497  
 498      /**
 499       * Checks, that the specified element is visible. Only available in tests using Javascript.
 500       *
 501       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" should be visible$/
 502       * @throws ElementNotFoundException
 503       * @throws ExpectationException
 504       * @throws DriverException
 505       * @param string $element
 506       * @param string $selectortype
 507       * @return void
 508       */
 509      public function should_be_visible($element, $selectortype) {
 510  
 511          if (!$this->running_javascript()) {
 512              throw new DriverException('Visible checks are disabled in scenarios without Javascript support');
 513          }
 514  
 515          $node = $this->get_selected_node($selectortype, $element);
 516          if (!$node->isVisible()) {
 517              throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is not visible', $this->getSession());
 518          }
 519      }
 520  
 521      /**
 522       * Checks, that the existing element is not visible. Only available in tests using Javascript.
 523       *
 524       * As a "not" method, it's performance could not be good, but in this
 525       * case the performance is good because the element must exist,
 526       * otherwise there would be a ElementNotFoundException, also here we are
 527       * not spinning until the element is visible.
 528       *
 529       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" should not be visible$/
 530       * @throws ElementNotFoundException
 531       * @throws ExpectationException
 532       * @param string $element
 533       * @param string $selectortype
 534       * @return void
 535       */
 536      public function should_not_be_visible($element, $selectortype) {
 537  
 538          try {
 539              $this->should_be_visible($element, $selectortype);
 540          } catch (ExpectationException $e) {
 541              // All as expected.
 542              return;
 543          }
 544          throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is visible', $this->getSession());
 545      }
 546  
 547      /**
 548       * Checks, that the specified element is visible inside the specified container. Only available in tests using Javascript.
 549       *
 550       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" in the "(?P<element_container_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)" should be visible$/
 551       * @throws ElementNotFoundException
 552       * @throws DriverException
 553       * @throws ExpectationException
 554       * @param string $element Element we look for
 555       * @param string $selectortype The type of what we look for
 556       * @param string $nodeelement Element we look in
 557       * @param string $nodeselectortype The type of selector where we look in
 558       */
 559      public function in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) {
 560  
 561          if (!$this->running_javascript()) {
 562              throw new DriverException('Visible checks are disabled in scenarios without Javascript support');
 563          }
 564  
 565          $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
 566          if (!$node->isVisible()) {
 567              throw new ExpectationException(
 568                  '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is not visible',
 569                  $this->getSession()
 570              );
 571          }
 572      }
 573  
 574      /**
 575       * Checks, that the existing element is not visible inside the existing container. Only available in tests using Javascript.
 576       *
 577       * As a "not" method, it's performance could not be good, but in this
 578       * case the performance is good because the element must exist,
 579       * otherwise there would be a ElementNotFoundException, also here we are
 580       * not spinning until the element is visible.
 581       *
 582       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" in the "(?P<element_container_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)" should not be visible$/
 583       * @throws ElementNotFoundException
 584       * @throws ExpectationException
 585       * @param string $element Element we look for
 586       * @param string $selectortype The type of what we look for
 587       * @param string $nodeelement Element we look in
 588       * @param string $nodeselectortype The type of selector where we look in
 589       */
 590      public function in_the_should_not_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) {
 591  
 592          try {
 593              $this->in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype);
 594          } catch (ExpectationException $e) {
 595              // All as expected.
 596              return;
 597          }
 598          throw new ExpectationException(
 599              '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is visible',
 600              $this->getSession()
 601          );
 602      }
 603  
 604      /**
 605       * Checks, that page contains specified text. It also checks if the text is visible when running Javascript tests.
 606       *
 607       * @Then /^I should see "(?P<text_string>(?:[^"]|\\")*)"$/
 608       * @throws ExpectationException
 609       * @param string $text
 610       */
 611      public function assert_page_contains_text($text) {
 612  
 613          // Looking for all the matching nodes without any other descendant matching the
 614          // same xpath (we are using contains(., ....).
 615          $xpathliteral = behat_context_helper::escape($text);
 616          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 617              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 618  
 619          try {
 620              $nodes = $this->find_all('xpath', $xpath);
 621          } catch (ElementNotFoundException $e) {
 622              throw new ExpectationException('"' . $text . '" text was not found in the page', $this->getSession());
 623          }
 624  
 625          // If we are not running javascript we have enough with the
 626          // element existing as we can't check if it is visible.
 627          if (!$this->running_javascript()) {
 628              return;
 629          }
 630  
 631          // We spin as we don't have enough checking that the element is there, we
 632          // should also ensure that the element is visible. Using microsleep as this
 633          // is a repeated step and global performance is important.
 634          $this->spin(
 635              function($context, $args) {
 636  
 637                  foreach ($args['nodes'] as $node) {
 638                      if ($node->isVisible()) {
 639                          return true;
 640                      }
 641                  }
 642  
 643                  // If non of the nodes is visible we loop again.
 644                  throw new ExpectationException('"' . $args['text'] . '" text was found but was not visible', $context->getSession());
 645              },
 646              array('nodes' => $nodes, 'text' => $text),
 647              false,
 648              false,
 649              true
 650          );
 651  
 652      }
 653  
 654      /**
 655       * Checks, that page doesn't contain specified text. When running Javascript tests it also considers that texts may be hidden.
 656       *
 657       * @Then /^I should not see "(?P<text_string>(?:[^"]|\\")*)"$/
 658       * @throws ExpectationException
 659       * @param string $text
 660       */
 661      public function assert_page_not_contains_text($text) {
 662  
 663          // Looking for all the matching nodes without any other descendant matching the
 664          // same xpath (we are using contains(., ....).
 665          $xpathliteral = behat_context_helper::escape($text);
 666          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 667              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 668  
 669          // We should wait a while to ensure that the page is not still loading elements.
 670          // Waiting less than self::get_timeout() as we already waited for the DOM to be ready and
 671          // all JS to be executed.
 672          try {
 673              $nodes = $this->find_all('xpath', $xpath, false, false, self::get_reduced_timeout());
 674          } catch (ElementNotFoundException $e) {
 675              // All ok.
 676              return;
 677          }
 678  
 679          // If we are not running javascript we have enough with the
 680          // element existing as we can't check if it is hidden.
 681          if (!$this->running_javascript()) {
 682              throw new ExpectationException('"' . $text . '" text was found in the page', $this->getSession());
 683          }
 684  
 685          // If the element is there we should be sure that it is not visible.
 686          $this->spin(
 687              function($context, $args) {
 688  
 689                  foreach ($args['nodes'] as $node) {
 690                      // If element is removed from dom, then just exit.
 691                      try {
 692                          // If element is visible then throw exception, so we keep spinning.
 693                          if ($node->isVisible()) {
 694                              throw new ExpectationException('"' . $args['text'] . '" text was found in the page',
 695                                  $context->getSession());
 696                          }
 697                      } catch (NoSuchElementException $e) {
 698                          // Do nothing just return, as element is no more on page.
 699                          return true;
 700                      } catch (ElementNotFoundException $e) {
 701                          // Do nothing just return, as element is no more on page.
 702                          return true;
 703                      }
 704                  }
 705  
 706                  // If non of the found nodes is visible we consider that the text is not visible.
 707                  return true;
 708              },
 709              array('nodes' => $nodes, 'text' => $text),
 710              behat_base::get_reduced_timeout(),
 711              false,
 712              true
 713          );
 714      }
 715  
 716      /**
 717       * Checks, that the specified element contains the specified text. When running Javascript tests it also considers that texts may be hidden.
 718       *
 719       * @Then /^I should see "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
 720       * @throws ElementNotFoundException
 721       * @throws ExpectationException
 722       * @param string $text
 723       * @param string $element Element we look in.
 724       * @param string $selectortype The type of element where we are looking in.
 725       */
 726      public function assert_element_contains_text($text, $element, $selectortype) {
 727  
 728          // Getting the container where the text should be found.
 729          $container = $this->get_selected_node($selectortype, $element);
 730  
 731          // Looking for all the matching nodes without any other descendant matching the
 732          // same xpath (we are using contains(., ....).
 733          $xpathliteral = behat_context_helper::escape($text);
 734          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 735              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 736  
 737          // Wait until it finds the text inside the container, otherwise custom exception.
 738          try {
 739              $nodes = $this->find_all('xpath', $xpath, false, $container);
 740          } catch (ElementNotFoundException $e) {
 741              throw new ExpectationException('"' . $text . '" text was not found in the "' . $element . '" element', $this->getSession());
 742          }
 743  
 744          // If we are not running javascript we have enough with the
 745          // element existing as we can't check if it is visible.
 746          if (!$this->running_javascript()) {
 747              return;
 748          }
 749  
 750          // We also check the element visibility when running JS tests. Using microsleep as this
 751          // is a repeated step and global performance is important.
 752          $this->spin(
 753              function($context, $args) {
 754  
 755                  foreach ($args['nodes'] as $node) {
 756                      if ($node->isVisible()) {
 757                          return true;
 758                      }
 759                  }
 760  
 761                  throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element but was not visible', $context->getSession());
 762              },
 763              array('nodes' => $nodes, 'text' => $text, 'element' => $element),
 764              false,
 765              false,
 766              true
 767          );
 768      }
 769  
 770      /**
 771       * Checks, that the specified element does not contain the specified text. When running Javascript tests it also considers that texts may be hidden.
 772       *
 773       * @Then /^I should not see "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
 774       * @throws ElementNotFoundException
 775       * @throws ExpectationException
 776       * @param string $text
 777       * @param string $element Element we look in.
 778       * @param string $selectortype The type of element where we are looking in.
 779       */
 780      public function assert_element_not_contains_text($text, $element, $selectortype) {
 781  
 782          // Getting the container where the text should be found.
 783          $container = $this->get_selected_node($selectortype, $element);
 784  
 785          // Looking for all the matching nodes without any other descendant matching the
 786          // same xpath (we are using contains(., ....).
 787          $xpathliteral = behat_context_helper::escape($text);
 788          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 789              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 790  
 791          // We should wait a while to ensure that the page is not still loading elements.
 792          // Giving preference to the reliability of the results rather than to the performance.
 793          try {
 794              $nodes = $this->find_all('xpath', $xpath, false, $container, self::get_reduced_timeout());
 795          } catch (ElementNotFoundException $e) {
 796              // All ok.
 797              return;
 798          }
 799  
 800          // If we are not running javascript we have enough with the
 801          // element not being found as we can't check if it is visible.
 802          if (!$this->running_javascript()) {
 803              throw new ExpectationException('"' . $text . '" text was found in the "' . $element . '" element', $this->getSession());
 804          }
 805  
 806          // We need to ensure all the found nodes are hidden.
 807          $this->spin(
 808              function($context, $args) {
 809  
 810                  foreach ($args['nodes'] as $node) {
 811                      if ($node->isVisible()) {
 812                          throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element', $context->getSession());
 813                      }
 814                  }
 815  
 816                  // If all the found nodes are hidden we are happy.
 817                  return true;
 818              },
 819              array('nodes' => $nodes, 'text' => $text, 'element' => $element),
 820              behat_base::get_reduced_timeout(),
 821              false,
 822              true
 823          );
 824      }
 825  
 826      /**
 827       * Checks, that the first specified element appears before the second one.
 828       *
 829       * @Then :preelement :preselectortype should appear before :postelement :postselectortype
 830       * @Then :preelement :preselectortype should appear before :postelement :postselectortype in the :containerelement :containerselectortype
 831       * @throws ExpectationException
 832       * @param string $preelement The locator of the preceding element
 833       * @param string $preselectortype The selector type of the preceding element
 834       * @param string $postelement The locator of the latest element
 835       * @param string $postselectortype The selector type of the latest element
 836       * @param string $containerelement
 837       * @param string $containerselectortype
 838       */
 839      public function should_appear_before(
 840          string $preelement,
 841          string $preselectortype,
 842          string $postelement,
 843          string $postselectortype,
 844          ?string $containerelement = null,
 845          ?string $containerselectortype = null
 846      ) {
 847          $msg = "'{$preelement}' '{$preselectortype}' does not appear before '{$postelement}' '{$postselectortype}'";
 848          $this->check_element_order(
 849              $containerelement,
 850              $containerselectortype,
 851              $preelement,
 852              $preselectortype,
 853              $postelement,
 854              $postselectortype,
 855              $msg
 856          );
 857      }
 858  
 859      /**
 860       * Checks, that the first specified element appears after the second one.
 861       *
 862       * @Then :postelement :postselectortype should appear after :preelement :preselectortype
 863       * @Then :postelement :postselectortype should appear after :preelement :preselectortype in the :containerelement :containerselectortype
 864       * @throws ExpectationException
 865       * @param string $postelement The locator of the latest element
 866       * @param string $postselectortype The selector type of the latest element
 867       * @param string $preelement The locator of the preceding element
 868       * @param string $preselectortype The selector type of the preceding element
 869       * @param string $containerelement
 870       * @param string $containerselectortype
 871       */
 872      public function should_appear_after(
 873          string $postelement,
 874          string $postselectortype,
 875          string $preelement,
 876          string $preselectortype,
 877          ?string $containerelement = null,
 878          ?string $containerselectortype = null
 879      ) {
 880          $msg = "'{$postelement}' '{$postselectortype}' does not appear after '{$preelement}' '{$preselectortype}'";
 881          $this->check_element_order(
 882              $containerelement,
 883              $containerselectortype,
 884              $preelement,
 885              $preselectortype,
 886              $postelement,
 887              $postselectortype,
 888              $msg
 889          );
 890      }
 891  
 892      /**
 893       * Shared code to check whether an element is before or after another one.
 894       *
 895       * @param string $containerelement
 896       * @param string $containerselectortype
 897       * @param string $preelement The locator of the preceding element
 898       * @param string $preselectortype The locator of the preceding element
 899       * @param string $postelement The locator of the following element
 900       * @param string $postselectortype The selector type of the following element
 901       * @param string $msg Message to output if this fails
 902       */
 903      protected function check_element_order(
 904          ?string $containerelement,
 905          ?string $containerselectortype,
 906          string $preelement,
 907          string $preselectortype,
 908          string $postelement,
 909          string $postselectortype,
 910          string $msg
 911      ) {
 912          $containernode = false;
 913          if ($containerselectortype && $containerelement) {
 914              // Get the container node.
 915              $containernode = $this->get_selected_node($containerselectortype, $containerelement);
 916              $msg .= " in the '{$containerelement}' '{$containerselectortype}'";
 917          }
 918  
 919          list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement);
 920          list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement);
 921  
 922          $newlines = [
 923              "\r\n",
 924              "\r",
 925              "\n",
 926          ];
 927          $prexpath = str_replace($newlines, ' ', $this->find($preselector, $prelocator, false, $containernode)->getXpath());
 928          $postxpath = str_replace($newlines, ' ', $this->find($postselector, $postlocator, false, $containernode)->getXpath());
 929  
 930          if ($this->running_javascript()) {
 931              // The xpath to do this was running really slowly on certain Chrome versions so we are using
 932              // this DOM method instead.
 933              $js = <<<EOF
 934  (function() {
 935      var a = document.evaluate("{$prexpath}", document, null, XPathResult.ANY_TYPE, null).iterateNext();
 936      var b = document.evaluate("{$postxpath}", document, null, XPathResult.ANY_TYPE, null).iterateNext();
 937      return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
 938  })()
 939  EOF;
 940              $ok = $this->evaluate_script($js);
 941          } else {
 942  
 943              // Using following xpath axe to find it.
 944              $xpath = "{$prexpath}/following::*[contains(., {$postxpath})]";
 945              $ok = $this->getSession()->getDriver()->find($xpath);
 946          }
 947  
 948          if (!$ok) {
 949              throw new ExpectationException($msg, $this->getSession());
 950          }
 951      }
 952  
 953      /**
 954       * Checks, that element of specified type is disabled.
 955       *
 956       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be disabled$/
 957       * @throws ExpectationException Thrown by behat_base::find
 958       * @param string $element Element we look in
 959       * @param string $selectortype The type of element where we are looking in.
 960       */
 961      public function the_element_should_be_disabled($element, $selectortype) {
 962          $this->the_attribute_of_should_be_set("disabled", $element, $selectortype, false);
 963      }
 964  
 965      /**
 966       * Checks, that element of specified type is enabled.
 967       *
 968       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be enabled$/
 969       * @throws ExpectationException Thrown by behat_base::find
 970       * @param string $element Element we look on
 971       * @param string $selectortype The type of where we look
 972       */
 973      public function the_element_should_be_enabled($element, $selectortype) {
 974          $this->the_attribute_of_should_be_set("disabled", $element, $selectortype, true);
 975      }
 976  
 977      /**
 978       * Checks the provided element and selector type are readonly on the current page.
 979       *
 980       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be readonly$/
 981       * @throws ExpectationException Thrown by behat_base::find
 982       * @param string $element Element we look in
 983       * @param string $selectortype The type of element where we are looking in.
 984       */
 985      public function the_element_should_be_readonly($element, $selectortype) {
 986          $this->the_attribute_of_should_be_set("readonly", $element, $selectortype, false);
 987      }
 988  
 989      /**
 990       * Checks the provided element and selector type are not readonly on the current page.
 991       *
 992       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not be readonly$/
 993       * @throws ExpectationException Thrown by behat_base::find
 994       * @param string $element Element we look in
 995       * @param string $selectortype The type of element where we are looking in.
 996       */
 997      public function the_element_should_not_be_readonly($element, $selectortype) {
 998          $this->the_attribute_of_should_be_set("readonly", $element, $selectortype, true);
 999      }
1000  
1001      /**
1002       * Checks the provided element and selector type exists in the current page.
1003       *
1004       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1005       *
1006       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exist$/
1007       * @throws ElementNotFoundException Thrown by behat_base::find
1008       * @param string $element The locator of the specified selector
1009       * @param string $selectortype The selector type
1010       */
1011      public function should_exist($element, $selectortype) {
1012          // Will throw an ElementNotFoundException if it does not exist.
1013          $this->find($selectortype, $element);
1014      }
1015  
1016      /**
1017       * Checks that the provided element and selector type not exists in the current page.
1018       *
1019       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1020       *
1021       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exist$/
1022       * @throws ExpectationException
1023       * @param string $element The locator of the specified selector
1024       * @param string $selectortype The selector type
1025       */
1026      public function should_not_exist($element, $selectortype) {
1027          // Will throw an ElementNotFoundException if it does not exist, but, actually it should not exist, so we try &
1028          // catch it.
1029          try {
1030              // The exception does not really matter as we will catch it and will never "explode".
1031              $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $element);
1032  
1033              // Using the spin method as we want a reduced timeout but there is no need for a 0.1 seconds interval
1034              // because in the optimistic case we will timeout.
1035              // If all goes good it will throw an ElementNotFoundExceptionn that we will catch.
1036              $this->find($selectortype, $element, $exception, false, behat_base::get_reduced_timeout());
1037          } catch (ElementNotFoundException $e) {
1038              // We expect the element to not be found.
1039              return;
1040          }
1041  
1042          // The element was found and should not have been. Throw an exception.
1043          throw new ExpectationException("The '{$element}' '{$selectortype}' exists in the current page", $this->getSession());
1044      }
1045  
1046      /**
1047       * Ensure that edit mode is (not) available on the current page.
1048       *
1049       * @Then edit mode should be available on the current page
1050       * @Then edit mode should :not be available on the current page
1051       * @param bool $not
1052       */
1053      public function edit_mode_should_be_available(bool $not = false): void {
1054          $isavailable = $this->is_edit_mode_available();
1055          $shouldbeavailable = empty($not);
1056  
1057          if ($isavailable && !$shouldbeavailable) {
1058              throw new ExpectationException("Edit mode is available and should not be", $this->getSession());
1059          } else if ($shouldbeavailable && !$isavailable) {
1060              throw new ExpectationException("Edit mode is not available and should be", $this->getSession());
1061          }
1062      }
1063  
1064      /**
1065       * Check whether edit mode is available on the current page.
1066       *
1067       * @return bool
1068       */
1069      public function is_edit_mode_available(): bool {
1070          // If the course is already in editing mode then it will have the class 'editing' on the body.
1071          // This is a 'cheap' way of telling if the course is in editing mode and therefore if edit mode is available.
1072          $body = $this->find('css', 'body');
1073          if ($body->hasClass('editing')) {
1074              return true;
1075          }
1076  
1077          try {
1078              $this->find('field', get_string('editmode'), false, false, 0);
1079              return true;
1080          } catch (ElementNotFoundException $e) {
1081              return false;
1082          }
1083      }
1084  
1085      /**
1086       * This step triggers cron like a user would do going to admin/cron.php.
1087       *
1088       * @Given /^I trigger cron$/
1089       */
1090      public function i_trigger_cron() {
1091          $this->execute('behat_general::i_visit', ['/admin/cron.php']);
1092      }
1093  
1094      /**
1095       * Runs a scheduled task immediately, given full class name.
1096       *
1097       * This is faster and more reliable than running cron (running cron won't
1098       * work more than once in the same test, for instance). However it is
1099       * a little less 'realistic'.
1100       *
1101       * While the task is running, we suppress mtrace output because it makes
1102       * the Behat result look ugly.
1103       *
1104       * Note: Most of the code relating to running a task is based on
1105       * admin/cli/scheduled_task.php.
1106       *
1107       * @Given /^I run the scheduled task "(?P<task_name>[^"]+)"$/
1108       * @param string $taskname Name of task e.g. 'mod_whatever\task\do_something'
1109       */
1110      public function i_run_the_scheduled_task($taskname) {
1111          global $CFG;
1112          require_once("{$CFG->libdir}/cronlib.php");
1113  
1114          $task = \core\task\manager::get_scheduled_task($taskname);
1115          if (!$task) {
1116              throw new DriverException('The "' . $taskname . '" scheduled task does not exist');
1117          }
1118  
1119          // Do setup for cron task.
1120          raise_memory_limit(MEMORY_EXTRA);
1121          cron_setup_user();
1122  
1123          // Get lock.
1124          $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
1125          if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
1126              throw new DriverException('Unable to obtain core_cron lock for scheduled task');
1127          }
1128          if (!$lock = $cronlockfactory->get_lock('\\' . get_class($task), 10)) {
1129              $cronlock->release();
1130              throw new DriverException('Unable to obtain task lock for scheduled task');
1131          }
1132          $task->set_lock($lock);
1133          if (!$task->is_blocking()) {
1134              $cronlock->release();
1135          } else {
1136              $task->set_cron_lock($cronlock);
1137          }
1138  
1139          try {
1140              // Prepare the renderer.
1141              cron_prepare_core_renderer();
1142  
1143              // Discard task output as not appropriate for Behat output!
1144              ob_start();
1145              $task->execute();
1146              ob_end_clean();
1147  
1148              // Restore the previous renderer.
1149              cron_prepare_core_renderer(true);
1150  
1151              // Mark task complete.
1152              \core\task\manager::scheduled_task_complete($task);
1153          } catch (Exception $e) {
1154              // Restore the previous renderer.
1155              cron_prepare_core_renderer(true);
1156  
1157              // Mark task failed and throw exception.
1158              \core\task\manager::scheduled_task_failed($task);
1159  
1160              throw new DriverException('The "' . $taskname . '" scheduled task failed', 0, $e);
1161          }
1162      }
1163  
1164      /**
1165       * Runs all ad-hoc tasks in the queue.
1166       *
1167       * This is faster and more reliable than running cron (running cron won't
1168       * work more than once in the same test, for instance). However it is
1169       * a little less 'realistic'.
1170       *
1171       * While the task is running, we suppress mtrace output because it makes
1172       * the Behat result look ugly.
1173       *
1174       * @Given /^I run all adhoc tasks$/
1175       * @throws DriverException
1176       */
1177      public function i_run_all_adhoc_tasks() {
1178          global $CFG, $DB;
1179          require_once("{$CFG->libdir}/cronlib.php");
1180  
1181          // Do setup for cron task.
1182          cron_setup_user();
1183  
1184          // Discard task output as not appropriate for Behat output!
1185          ob_start();
1186  
1187          // Run all tasks which have a scheduled runtime of before now.
1188          $timenow = time();
1189  
1190          while (!\core\task\manager::static_caches_cleared_since($timenow) &&
1191                  $task = \core\task\manager::get_next_adhoc_task($timenow)) {
1192              // Clean the output buffer between tasks.
1193              ob_clean();
1194  
1195              // Run the task.
1196              cron_run_inner_adhoc_task($task);
1197  
1198              // Check whether the task record still exists.
1199              // If a task was successful it will be removed.
1200              // If it failed then it will still exist.
1201              if ($DB->record_exists('task_adhoc', ['id' => $task->get_id()])) {
1202                  // End ouptut buffering and flush the current buffer.
1203                  // This should be from just the current task.
1204                  ob_end_flush();
1205  
1206                  throw new DriverException('An adhoc task failed', 0);
1207              }
1208          }
1209          ob_end_clean();
1210      }
1211  
1212      /**
1213       * Checks that an element and selector type exists in another element and selector type on the current page.
1214       *
1215       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1216       *
1217       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exist in the "(?P<element2_string>(?:[^"]|\\")*)" "(?P<selector2_string>[^"]*)"$/
1218       * @throws ElementNotFoundException Thrown by behat_base::find
1219       * @param string $element The locator of the specified selector
1220       * @param string $selectortype The selector type
1221       * @param NodeElement|string $containerelement The locator of the container selector
1222       * @param string $containerselectortype The container selector type
1223       */
1224      public function should_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) {
1225          // Will throw an ElementNotFoundException if it does not exist.
1226          $this->get_node_in_container($selectortype, $element, $containerselectortype, $containerelement);
1227      }
1228  
1229      /**
1230       * Checks that an element and selector type does not exist in another element and selector type on the current page.
1231       *
1232       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1233       *
1234       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exist in the "(?P<element2_string>(?:[^"]|\\")*)" "(?P<selector2_string>[^"]*)"$/
1235       * @throws ExpectationException
1236       * @param string $element The locator of the specified selector
1237       * @param string $selectortype The selector type
1238       * @param NodeElement|string $containerelement The locator of the container selector
1239       * @param string $containerselectortype The container selector type
1240       */
1241      public function should_not_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) {
1242          // Get the container node.
1243          $containernode = $this->find($containerselectortype, $containerelement);
1244  
1245          // Will throw an ElementNotFoundException if it does not exist, but, actually it should not exist, so we try &
1246          // catch it.
1247          try {
1248              // Looks for the requested node inside the container node.
1249              $this->find($selectortype, $element, false, $containernode, behat_base::get_reduced_timeout());
1250          } catch (ElementNotFoundException $e) {
1251              // We expect the element to not be found.
1252              return;
1253          }
1254  
1255          // The element was found and should not have been. Throw an exception.
1256          $elementdescription = $this->get_selector_description($selectortype, $element);
1257          $containerdescription = $this->get_selector_description($containerselectortype, $containerelement);
1258          throw new ExpectationException(
1259              "The {$elementdescription} exists in the {$containerdescription}",
1260              $this->getSession()
1261          );
1262      }
1263  
1264      /**
1265       * Change browser window size
1266       *
1267       * Allowed sizes:
1268       * - mobile: 425x750
1269       * - tablet: 768x1024
1270       * - small: 1024x768
1271       * - medium: 1366x768
1272       * - large: 2560x1600
1273       * - custom: widthxheight
1274       *
1275       * Example: I change window size to "small" or I change window size to "1024x768"
1276       * or I change viewport size to "800x600". The viewport option is useful to guarantee that the
1277       * browser window has same viewport size even when you run Behat on multiple operating systems.
1278       *
1279       * @throws ExpectationException
1280       * @Then /^I change (window|viewport) size to "(mobile|tablet|small|medium|large|\d+x\d+)"$/
1281       * @Then /^I change the (window|viewport) size to "(mobile|tablet|small|medium|large|\d+x\d+)"$/
1282       * @param string $windowsize size of the window (mobile|tablet|small|medium|large|wxh).
1283       */
1284      public function i_change_window_size_to($windowviewport, $windowsize) {
1285          $this->resize_window($windowsize, $windowviewport === 'viewport');
1286      }
1287  
1288      /**
1289       * Checks whether there the specified attribute is set or not.
1290       *
1291       * @Then the :attribute attribute of :element :selectortype should be set
1292       * @Then the :attribute attribute of :element :selectortype should :not be set
1293       *
1294       * @throws ExpectationException
1295       * @param string $attribute Name of attribute
1296       * @param string $element The locator of the specified selector
1297       * @param string $selectortype The selector type
1298       * @param string $not
1299       */
1300      public function the_attribute_of_should_be_set($attribute, $element, $selectortype, $not = null) {
1301          // Get the container node (exception if it doesn't exist).
1302          $containernode = $this->get_selected_node($selectortype, $element);
1303          $hasattribute = $containernode->hasAttribute($attribute);
1304  
1305          if ($not && $hasattribute) {
1306              $value = $containernode->getAttribute($attribute);
1307              // Should not be set but is.
1308              throw new ExpectationException(
1309                  "The attribute \"{$attribute}\" should not be set but has a value of '{$value}'",
1310                  $this->getSession()
1311              );
1312          } else if (!$not && !$hasattribute) {
1313              // Should be set but is not.
1314              throw new ExpectationException(
1315                  "The attribute \"{$attribute}\" should be set but is not",
1316                  $this->getSession()
1317              );
1318          }
1319      }
1320  
1321      /**
1322       * Checks whether there is an attribute on the given element that contains the specified text.
1323       *
1324       * @Then /^the "(?P<attribute_string>[^"]*)" attribute of "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should contain "(?P<text_string>(?:[^"]|\\")*)"$/
1325       * @throws ExpectationException
1326       * @param string $attribute Name of attribute
1327       * @param string $element The locator of the specified selector
1328       * @param string $selectortype The selector type
1329       * @param string $text Expected substring
1330       */
1331      public function the_attribute_of_should_contain($attribute, $element, $selectortype, $text) {
1332          // Get the container node (exception if it doesn't exist).
1333          $containernode = $this->get_selected_node($selectortype, $element);
1334          $value = $containernode->getAttribute($attribute);
1335          if ($value == null) {
1336              throw new ExpectationException('The attribute "' . $attribute. '" does not exist',
1337                      $this->getSession());
1338          } else if (strpos($value, $text) === false) {
1339              throw new ExpectationException('The attribute "' . $attribute .
1340                      '" does not contain "' . $text . '" (actual value: "' . $value . '")',
1341                      $this->getSession());
1342          }
1343      }
1344  
1345      /**
1346       * Checks that the attribute on the given element does not contain the specified text.
1347       *
1348       * @Then /^the "(?P<attribute_string>[^"]*)" attribute of "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not contain "(?P<text_string>(?:[^"]|\\")*)"$/
1349       * @throws ExpectationException
1350       * @param string $attribute Name of attribute
1351       * @param string $element The locator of the specified selector
1352       * @param string $selectortype The selector type
1353       * @param string $text Expected substring
1354       */
1355      public function the_attribute_of_should_not_contain($attribute, $element, $selectortype, $text) {
1356          // Get the container node (exception if it doesn't exist).
1357          $containernode = $this->get_selected_node($selectortype, $element);
1358          $value = $containernode->getAttribute($attribute);
1359          if ($value == null) {
1360              throw new ExpectationException('The attribute "' . $attribute. '" does not exist',
1361                      $this->getSession());
1362          } else if (strpos($value, $text) !== false) {
1363              throw new ExpectationException('The attribute "' . $attribute .
1364                      '" contains "' . $text . '" (value: "' . $value . '")',
1365                      $this->getSession());
1366          }
1367      }
1368  
1369      /**
1370       * Checks the provided value exists in specific row/column of table.
1371       *
1372       * @Then /^"(?P<row_string>[^"]*)" row "(?P<column_string>[^"]*)" column of "(?P<table_string>[^"]*)" table should contain "(?P<value_string>[^"]*)"$/
1373       * @throws ElementNotFoundException
1374       * @param string $row row text which will be looked in.
1375       * @param string $column column text to search (or numeric value for the column position)
1376       * @param string $table table id/class/caption
1377       * @param string $value text to check.
1378       */
1379      public function row_column_of_table_should_contain($row, $column, $table, $value) {
1380          $tablenode = $this->get_selected_node('table', $table);
1381          $tablexpath = $tablenode->getXpath();
1382  
1383          $rowliteral = behat_context_helper::escape($row);
1384          $valueliteral = behat_context_helper::escape($value);
1385          $columnliteral = behat_context_helper::escape($column);
1386  
1387          if (preg_match('/^-?(\d+)-?$/', $column, $columnasnumber)) {
1388              // Column indicated as a number, just use it as position of the column.
1389              $columnpositionxpath = "/child::*[position() = {$columnasnumber[1]}]";
1390          } else {
1391              // Header can be in thead or tbody (first row), following xpath should work.
1392              $theadheaderxpath = "thead/tr[1]/th[(normalize-space(.)=" . $columnliteral . " or a[normalize-space(text())=" .
1393                      $columnliteral . "] or div[normalize-space(text())=" . $columnliteral . "])]";
1394              $tbodyheaderxpath = "tbody/tr[1]/td[(normalize-space(.)=" . $columnliteral . " or a[normalize-space(text())=" .
1395                      $columnliteral . "] or div[normalize-space(text())=" . $columnliteral . "])]";
1396  
1397              // Check if column exists.
1398              $columnheaderxpath = $tablexpath . "[" . $theadheaderxpath . " | " . $tbodyheaderxpath . "]";
1399              $columnheader = $this->getSession()->getDriver()->find($columnheaderxpath);
1400              if (empty($columnheader)) {
1401                  $columnexceptionmsg = $column . '" in table "' . $table . '"';
1402                  throw new ElementNotFoundException($this->getSession(), "\n$columnheaderxpath\n\n".'Column', null, $columnexceptionmsg);
1403              }
1404              // Following conditions were considered before finding column count.
1405              // 1. Table header can be in thead/tr/th or tbody/tr/td[1].
1406              // 2. First column can have th (Gradebook -> user report), so having lenient sibling check.
1407              $columnpositionxpath = "/child::*[position() = count(" . $tablexpath . "/" . $theadheaderxpath .
1408                  "/preceding-sibling::*) + 1]";
1409          }
1410  
1411          // Check if value exists in specific row/column.
1412          // Get row xpath.
1413          // GoutteDriver uses DomCrawler\Crawler and it is making XPath relative to the current context, so use descendant.
1414          $rowxpath = $tablexpath."/tbody/tr[descendant::th[normalize-space(.)=" . $rowliteral .
1415                      "] | descendant::td[normalize-space(.)=" . $rowliteral . "]]";
1416  
1417          $columnvaluexpath = $rowxpath . $columnpositionxpath . "[contains(normalize-space(.)," . $valueliteral . ")]";
1418  
1419          // Looks for the requested node inside the container node.
1420          $coumnnode = $this->getSession()->getDriver()->find($columnvaluexpath);
1421          if (empty($coumnnode)) {
1422              $locatorexceptionmsg = $value . '" in "' . $row . '" row with column "' . $column;
1423              throw new ElementNotFoundException($this->getSession(), "\n$columnvaluexpath\n\n".'Column value', null, $locatorexceptionmsg);
1424          }
1425      }
1426  
1427      /**
1428       * Checks the provided value should not exist in specific row/column of table.
1429       *
1430       * @Then /^"(?P<row_string>[^"]*)" row "(?P<column_string>[^"]*)" column of "(?P<table_string>[^"]*)" table should not contain "(?P<value_string>[^"]*)"$/
1431       * @throws ElementNotFoundException
1432       * @param string $row row text which will be looked in.
1433       * @param string $column column text to search
1434       * @param string $table table id/class/caption
1435       * @param string $value text to check.
1436       */
1437      public function row_column_of_table_should_not_contain($row, $column, $table, $value) {
1438          try {
1439              $this->row_column_of_table_should_contain($row, $column, $table, $value);
1440          } catch (ElementNotFoundException $e) {
1441              // Table row/column doesn't contain this value. Nothing to do.
1442              return;
1443          }
1444          // Throw exception if found.
1445          throw new ExpectationException(
1446              '"' . $column . '" with value "' . $value . '" is present in "' . $row . '"  row for table "' . $table . '"',
1447              $this->getSession()
1448          );
1449      }
1450  
1451      /**
1452       * Checks that the provided value exist in table.
1453       *
1454       * First row may contain column headers or numeric indexes of the columns
1455       * (syntax -1- is also considered to be column index). Column indexes are
1456       * useful in case of multirow headers and/or presence of cells with colspan.
1457       *
1458       * @Then /^the following should exist in the "(?P<table_string>[^"]*)" table:$/
1459       * @throws ExpectationException
1460       * @param string $table name of table
1461       * @param TableNode $data table with first row as header and following values
1462       *        | Header 1 | Header 2 | Header 3 |
1463       *        | Value 1 | Value 2 | Value 3|
1464       */
1465      public function following_should_exist_in_the_table($table, TableNode $data) {
1466          $datahash = $data->getHash();
1467  
1468          foreach ($datahash as $row) {
1469              $firstcell = null;
1470              foreach ($row as $column => $value) {
1471                  if ($firstcell === null) {
1472                      $firstcell = $value;
1473                  } else {
1474                      $this->row_column_of_table_should_contain($firstcell, $column, $table, $value);
1475                  }
1476              }
1477          }
1478      }
1479  
1480      /**
1481       * Checks that the provided values do not exist in a table.
1482       *
1483       * @Then /^the following should not exist in the "(?P<table_string>[^"]*)" table:$/
1484       * @throws ExpectationException
1485       * @param string $table name of table
1486       * @param TableNode $data table with first row as header and following values
1487       *        | Header 1 | Header 2 | Header 3 |
1488       *        | Value 1 | Value 2 | Value 3|
1489       */
1490      public function following_should_not_exist_in_the_table($table, TableNode $data) {
1491          $datahash = $data->getHash();
1492  
1493          foreach ($datahash as $value) {
1494              $row = array_shift($value);
1495              foreach ($value as $column => $value) {
1496                  try {
1497                      $this->row_column_of_table_should_contain($row, $column, $table, $value);
1498                      // Throw exception if found.
1499                  } catch (ElementNotFoundException $e) {
1500                      // Table row/column doesn't contain this value. Nothing to do.
1501                      continue;
1502                  }
1503                  throw new ExpectationException('"' . $column . '" with value "' . $value . '" is present in "' .
1504                      $row . '"  row for table "' . $table . '"', $this->getSession()
1505                  );
1506              }
1507          }
1508      }
1509  
1510      /**
1511       * Given the text of a link, download the linked file and return the contents.
1512       *
1513       * This is a helper method used by {@link following_should_download_bytes()}
1514       * and {@link following_should_download_between_and_bytes()}
1515       *
1516       * @param string $link the text of the link.
1517       * @return string the content of the downloaded file.
1518       */
1519      public function download_file_from_link($link) {
1520          // Find the link.
1521          $linknode = $this->find_link($link);
1522          $this->ensure_node_is_visible($linknode);
1523  
1524          // Get the href and check it.
1525          $url = $linknode->getAttribute('href');
1526          if (!$url) {
1527              throw new ExpectationException('Download link does not have href attribute',
1528                      $this->getSession());
1529          }
1530          if (!preg_match('~^https?://~', $url)) {
1531              throw new ExpectationException('Download link not an absolute URL: ' . $url,
1532                      $this->getSession());
1533          }
1534  
1535          // Download the URL and check the size.
1536          $session = $this->getSession()->getCookie('MoodleSession');
1537          return download_file_content($url, array('Cookie' => 'MoodleSession=' . $session));
1538      }
1539  
1540      /**
1541       * Downloads the file from a link on the page and checks the size.
1542       *
1543       * Only works if the link has an href attribute. Javascript downloads are
1544       * not supported. Currently, the href must be an absolute URL.
1545       *
1546       * @Then /^following "(?P<link_string>[^"]*)" should download "(?P<expected_bytes>\d+)" bytes$/
1547       * @throws ExpectationException
1548       * @param string $link the text of the link.
1549       * @param number $expectedsize the expected file size in bytes.
1550       */
1551      public function following_should_download_bytes($link, $expectedsize) {
1552          $exception = new ExpectationException('Error while downloading data from ' . $link, $this->getSession());
1553  
1554          // It will stop spinning once file is downloaded or time out.
1555          $result = $this->spin(
1556              function($context, $args) {
1557                  $link = $args['link'];
1558                  return $this->download_file_from_link($link);
1559              },
1560              array('link' => $link),
1561              behat_base::get_extended_timeout(),
1562              $exception
1563          );
1564  
1565          // Check download size.
1566          $actualsize = (int)strlen($result);
1567          if ($actualsize !== (int)$expectedsize) {
1568              throw new ExpectationException('Downloaded data was ' . $actualsize .
1569                      ' bytes, expecting ' . $expectedsize, $this->getSession());
1570          }
1571      }
1572  
1573      /**
1574       * Downloads the file from a link on the page and checks the size is in a given range.
1575       *
1576       * Only works if the link has an href attribute. Javascript downloads are
1577       * not supported. Currently, the href must be an absolute URL.
1578       *
1579       * The range includes the endpoints. That is, a 10 byte file in considered to
1580       * be between "5" and "10" bytes, and between "10" and "20" bytes.
1581       *
1582       * @Then /^following "(?P<link_string>[^"]*)" should download between "(?P<min_bytes>\d+)" and "(?P<max_bytes>\d+)" bytes$/
1583       * @throws ExpectationException
1584       * @param string $link the text of the link.
1585       * @param number $minexpectedsize the minimum expected file size in bytes.
1586       * @param number $maxexpectedsize the maximum expected file size in bytes.
1587       */
1588      public function following_should_download_between_and_bytes($link, $minexpectedsize, $maxexpectedsize) {
1589          // If the minimum is greater than the maximum then swap the values.
1590          if ((int)$minexpectedsize > (int)$maxexpectedsize) {
1591              list($minexpectedsize, $maxexpectedsize) = array($maxexpectedsize, $minexpectedsize);
1592          }
1593  
1594          $exception = new ExpectationException('Error while downloading data from ' . $link, $this->getSession());
1595  
1596          // It will stop spinning once file is downloaded or time out.
1597          $result = $this->spin(
1598              function($context, $args) {
1599                  $link = $args['link'];
1600  
1601                  return $this->download_file_from_link($link);
1602              },
1603              array('link' => $link),
1604              behat_base::get_extended_timeout(),
1605              $exception
1606          );
1607  
1608          // Check download size.
1609          $actualsize = (int)strlen($result);
1610          if ($actualsize < $minexpectedsize || $actualsize > $maxexpectedsize) {
1611              throw new ExpectationException('Downloaded data was ' . $actualsize .
1612                      ' bytes, expecting between ' . $minexpectedsize . ' and ' .
1613                      $maxexpectedsize, $this->getSession());
1614          }
1615      }
1616  
1617      /**
1618       * Checks that the image on the page is the same as one of the fixture files
1619       *
1620       * @Then /^the image at "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be identical to "(?P<filepath_string>(?:[^"]|\\")*)"$/
1621       * @throws ExpectationException
1622       * @param string $element The locator of the image
1623       * @param string $selectortype The selector type
1624       * @param string $filepath path to the fixture file
1625       */
1626      public function the_image_at_should_be_identical_to($element, $selectortype, $filepath) {
1627          global $CFG;
1628  
1629          // Get the container node (exception if it doesn't exist).
1630          $containernode = $this->get_selected_node($selectortype, $element);
1631          $url = $containernode->getAttribute('src');
1632          if ($url == null) {
1633              throw new ExpectationException('Element does not have src attribute',
1634                  $this->getSession());
1635          }
1636          $session = $this->getSession()->getCookie('MoodleSession');
1637          $content = download_file_content($url, array('Cookie' => 'MoodleSession=' . $session));
1638  
1639          // Get the content of the fixture file.
1640          // Replace 'admin/' if it is in start of path with $CFG->admin .
1641          if (substr($filepath, 0, 6) === 'admin/') {
1642              $filepath = $CFG->admin . DIRECTORY_SEPARATOR . substr($filepath, 6);
1643          }
1644          $filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath);
1645          $filepath = $CFG->dirroot . DIRECTORY_SEPARATOR . $filepath;
1646          if (!is_readable($filepath)) {
1647              throw new ExpectationException('The file to compare to does not exist.', $this->getSession());
1648          }
1649          $expectedcontent = file_get_contents($filepath);
1650  
1651          if ($content !== $expectedcontent) {
1652              throw new ExpectationException('Image is not identical to the fixture. Received ' .
1653              strlen($content) . ' bytes and expected ' . strlen($expectedcontent) . ' bytes', $this->getSession());
1654          }
1655      }
1656  
1657      /**
1658       * Prepare to detect whether or not a new page has loaded (or the same page reloaded) some time in the future.
1659       *
1660       * @Given /^I start watching to see if a new page loads$/
1661       */
1662      public function i_start_watching_to_see_if_a_new_page_loads() {
1663          if (!$this->running_javascript()) {
1664              throw new DriverException('Page load detection requires JavaScript.');
1665          }
1666  
1667          $session = $this->getSession();
1668  
1669          if ($this->pageloaddetectionrunning || $session->getPage()->find('xpath', $this->get_page_load_xpath())) {
1670              // If we find this node at this point we are already watching for a reload and the behat steps
1671              // are out of order. We will treat this as an error - really it needs to be fixed as it indicates a problem.
1672              throw new ExpectationException(
1673                  'Page load expectation error: page reloads are already been watched for.', $session);
1674          }
1675  
1676          $this->pageloaddetectionrunning = true;
1677  
1678          $this->execute_script(
1679              'var span = document.createElement("span");
1680              span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
1681              span.setAttribute("style", "display: none;");
1682              document.body.appendChild(span);'
1683          );
1684      }
1685  
1686      /**
1687       * Verify that a new page has loaded (or the same page has reloaded) since the
1688       * last "I start watching to see if a new page loads" step.
1689       *
1690       * @Given /^a new page should have loaded since I started watching$/
1691       */
1692      public function a_new_page_should_have_loaded_since_i_started_watching() {
1693          $session = $this->getSession();
1694  
1695          // Make sure page load tracking was started.
1696          if (!$this->pageloaddetectionrunning) {
1697              throw new ExpectationException(
1698                  'Page load expectation error: page load tracking was not started.', $session);
1699          }
1700  
1701          // As the node is inserted by code above it is either there or not, and we do not need spin and it is safe
1702          // to use the native API here which is great as exception handling (the alternative is slow).
1703          if ($session->getPage()->find('xpath', $this->get_page_load_xpath())) {
1704              // We don't want to find this node, if we do we have an error.
1705              throw new ExpectationException(
1706                  'Page load expectation error: a new page has not been loaded when it should have been.', $session);
1707          }
1708  
1709          // Cancel the tracking of pageloaddetectionrunning.
1710          $this->pageloaddetectionrunning = false;
1711      }
1712  
1713      /**
1714       * Verify that a new page has not loaded (or the same page has reloaded) since the
1715       * last "I start watching to see if a new page loads" step.
1716       *
1717       * @Given /^a new page should not have loaded since I started watching$/
1718       */
1719      public function a_new_page_should_not_have_loaded_since_i_started_watching() {
1720          $session = $this->getSession();
1721  
1722          // Make sure page load tracking was started.
1723          if (!$this->pageloaddetectionrunning) {
1724              throw new ExpectationException(
1725                  'Page load expectation error: page load tracking was not started.', $session);
1726          }
1727  
1728          // We use our API here as we can use the exception handling provided by it.
1729          $this->find(
1730              'xpath',
1731              $this->get_page_load_xpath(),
1732              new ExpectationException(
1733                  'Page load expectation error: A new page has been loaded when it should not have been.',
1734                  $this->getSession()
1735              )
1736          );
1737      }
1738  
1739      /**
1740       * Helper used by {@link a_new_page_should_have_loaded_since_i_started_watching}
1741       * and {@link a_new_page_should_not_have_loaded_since_i_started_watching}
1742       * @return string xpath expression.
1743       */
1744      protected function get_page_load_xpath() {
1745          return "//span[@data-rel = '" . self::PAGE_LOAD_DETECTION_STRING . "']";
1746      }
1747  
1748      /**
1749       * Wait unit user press Enter/Return key. Useful when debugging a scenario.
1750       *
1751       * @Then /^(?:|I )pause(?:| scenario execution)$/
1752       */
1753      public function i_pause_scenario_execution() {
1754          $message = "<colour:lightYellow>Paused. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.";
1755          behat_util::pause($this->getSession(), $message);
1756      }
1757  
1758      /**
1759       * Presses a given button in the browser.
1760       * NOTE: Phantomjs and goutte driver reloads page while navigating back and forward.
1761       *
1762       * @Then /^I press the "(back|forward|reload)" button in the browser$/
1763       * @param string $button the button to press.
1764       * @throws ExpectationException
1765       */
1766      public function i_press_in_the_browser($button) {
1767          $session = $this->getSession();
1768  
1769          if ($button == 'back') {
1770              $session->back();
1771          } else if ($button == 'forward') {
1772              $session->forward();
1773          } else if ($button == 'reload') {
1774              $session->reload();
1775          } else {
1776              throw new ExpectationException('Unknown browser button.', $session);
1777          }
1778      }
1779  
1780      /**
1781       * Send key presses to the browser without first changing focusing, or applying the key presses to a specific
1782       * element.
1783       *
1784       * Example usage of this step:
1785       *     When I type "Penguin"
1786       *
1787       * @When    I type :keys
1788       * @param   string $keys The key, or list of keys, to type
1789       */
1790      public function i_type(string $keys): void {
1791          // Certain keys, such as the newline character, must be converted to the appropriate character code.
1792          // Without this, keys will behave differently depending on the browser.
1793          $keylist = array_map(function($key): string {
1794              switch ($key) {
1795                  case "\n":
1796                      return behat_keys::ENTER;
1797                  default:
1798                      return $key;
1799              }
1800          }, str_split($keys));
1801          behat_base::type_keys($this->getSession(), $keylist);
1802      }
1803  
1804      /**
1805       * Press a named or character key with an optional set of modifiers.
1806       *
1807       * Supported named keys are:
1808       * - up
1809       * - down
1810       * - left
1811       * - right
1812       * - pageup|page_up
1813       * - pagedown|page_down
1814       * - home
1815       * - end
1816       * - insert
1817       * - delete
1818       * - backspace
1819       * - escape
1820       * - enter
1821       * - tab
1822       *
1823       * You can also use a single character for the key name e.g. 'Ctrl C'.
1824       *
1825       * Supported moderators are:
1826       * - shift
1827       * - ctrl
1828       * - alt
1829       * - meta
1830       *
1831       * Example usage of this new step:
1832       *     When I press the up key
1833       *     When I press the space key
1834       *     When I press the shift tab key
1835       *
1836       * Multiple moderator keys can be combined using the '+' operator, for example:
1837       *     When I press the ctrl+shift enter key
1838       *     When I press the ctrl + shift enter key
1839       *
1840       * @When    /^I press the (?P<modifiers_string>.* )?(?P<key_string>.*) key$/
1841       * @param   string $modifiers A list of keyboard modifiers, separated by the `+` character
1842       * @param   string $key The name of the key to press
1843       */
1844      public function i_press_named_key(string $modifiers, string $key): void {
1845          behat_base::require_javascript_in_session($this->getSession());
1846  
1847          $keys = [];
1848  
1849          foreach (explode('+', $modifiers) as $modifier) {
1850              switch (strtoupper(trim($modifier))) {
1851                  case '':
1852                      break;
1853                  case 'SHIFT':
1854                      $keys[] = behat_keys::SHIFT;
1855                      break;
1856                  case 'CTRL':
1857                      $keys[] = behat_keys::CONTROL;
1858                      break;
1859                  case 'ALT':
1860                      $keys[] = behat_keys::ALT;
1861                      break;
1862                  case 'META':
1863                      $keys[] = behat_keys::META;
1864                      break;
1865                  default:
1866                      throw new \coding_exception("Unknown modifier key '$modifier'}");
1867              }
1868          }
1869  
1870          $modifier = trim($key);
1871          switch (strtoupper($key)) {
1872              case 'UP':
1873                  $keys[] = behat_keys::ARROW_UP;
1874                  break;
1875              case 'DOWN':
1876                  $keys[] = behat_keys::ARROW_DOWN;
1877                  break;
1878              case 'LEFT':
1879                  $keys[] = behat_keys::ARROW_LEFT;
1880                  break;
1881              case 'RIGHT':
1882                  $keys[] = behat_keys::ARROW_RIGHT;
1883                  break;
1884              case 'HOME':
1885                  $keys[] = behat_keys::HOME;
1886                  break;
1887              case 'END':
1888                  $keys[] = behat_keys::END;
1889                  break;
1890              case 'INSERT':
1891                  $keys[] = behat_keys::INSERT;
1892                  break;
1893              case 'BACKSPACE':
1894                  $keys[] = behat_keys::BACKSPACE;
1895                  break;
1896              case 'DELETE':
1897                  $keys[] = behat_keys::DELETE;
1898                  break;
1899              case 'PAGEUP':
1900              case 'PAGE_UP':
1901                  $keys[] = behat_keys::PAGE_UP;
1902                  break;
1903              case 'PAGEDOWN':
1904              case 'PAGE_DOWN':
1905                  $keys[] = behat_keys::PAGE_DOWN;
1906                  break;
1907              case 'ESCAPE':
1908                  $keys[] = behat_keys::ESCAPE;
1909                  break;
1910              case 'ENTER':
1911                  $keys[] = behat_keys::ENTER;
1912                  break;
1913              case 'TAB':
1914                  $keys[] = behat_keys::TAB;
1915                  break;
1916              case 'SPACE':
1917                  $keys[] = behat_keys::SPACE;
1918                  break;
1919              case 'MULTIPLY':
1920                  $keys[] = behat_keys::MULTIPLY;
1921                  break;
1922              default:
1923                  // You can enter a single ASCII character (e.g. a letter) to directly type that key.
1924                  if (strlen($key) === 1) {
1925                      $keys[] = strtolower($key);
1926                  } else {
1927                      throw new \coding_exception("Unknown key '$key'}");
1928                  }
1929          }
1930  
1931          behat_base::type_keys($this->getSession(), $keys);
1932      }
1933  
1934      /**
1935       * Trigger a keydown event for a key on a specific element.
1936       *
1937       * @When /^I press key "(?P<key_string>(?:[^"]|\\")*)" in "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
1938       * @param string $key either char-code or character itself,
1939       *               may optionally be prefixed with ctrl-, alt-, shift- or meta-
1940       * @param string $element Element we look for
1941       * @param string $selectortype The type of what we look for
1942       * @throws DriverException
1943       * @throws ExpectationException
1944       */
1945      public function i_press_key_in_element($key, $element, $selectortype) {
1946          if (!$this->running_javascript()) {
1947              throw new DriverException('Key down step is not available with Javascript disabled');
1948          }
1949          // Gets the node based on the requested selector type and locator.
1950          $node = $this->get_selected_node($selectortype, $element);
1951          $modifier = null;
1952          $validmodifiers = array('ctrl', 'alt', 'shift', 'meta');
1953          $char = $key;
1954          if (strpos($key, '-')) {
1955              list($modifier, $char) = preg_split('/-/', $key, 2);
1956              $modifier = strtolower($modifier);
1957              if (!in_array($modifier, $validmodifiers)) {
1958                  throw new ExpectationException(sprintf('Unknown key modifier: %s.', $modifier),
1959                      $this->getSession());
1960              }
1961          }
1962          if (is_numeric($char)) {
1963              $char = (int)$char;
1964          }
1965  
1966          $node->keyDown($char, $modifier);
1967          $node->keyPress($char, $modifier);
1968          $node->keyUp($char, $modifier);
1969      }
1970  
1971      /**
1972       * Press tab key on a specific element.
1973       *
1974       * @When /^I press tab key in "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
1975       * @param string $element Element we look for
1976       * @param string $selectortype The type of what we look for
1977       * @throws DriverException
1978       * @throws ExpectationException
1979       */
1980      public function i_post_tab_key_in_element($element, $selectortype) {
1981          if (!$this->running_javascript()) {
1982              throw new DriverException('Tab press step is not available with Javascript disabled');
1983          }
1984          // Gets the node based on the requested selector type and locator.
1985          $node = $this->get_selected_node($selectortype, $element);
1986          $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
1987          $this->execute('behat_general::i_press_named_key', ['', 'tab']);
1988      }
1989  
1990      /**
1991       * Checks if database family used is using one of the specified, else skip. (mysql, postgres, mssql, oracle, etc.)
1992       *
1993       * @Given /^database family used is one of the following:$/
1994       * @param TableNode $databasefamilies list of database.
1995       * @return void.
1996       * @throws \Moodle\BehatExtension\Exception\SkippedException
1997       */
1998      public function database_family_used_is_one_of_the_following(TableNode $databasefamilies) {
1999          global $DB;
2000  
2001          $dbfamily = $DB->get_dbfamily();
2002  
2003          // Check if used db family is one of the specified ones. If yes then return.
2004          foreach ($databasefamilies->getRows() as $dbfamilytocheck) {
2005              if ($dbfamilytocheck[0] == $dbfamily) {
2006                  return;
2007              }
2008          }
2009  
2010          throw new \Moodle\BehatExtension\Exception\SkippedException();
2011      }
2012  
2013      /**
2014       * Checks if given plugin is installed, and skips the current scenario if not.
2015       *
2016       * @Given the :plugin plugin is installed
2017       * @param string $plugin frankenstyle plugin name, e.g. 'filter_embedquestion'.
2018       * @throws \Moodle\BehatExtension\Exception\SkippedException
2019       */
2020      public function plugin_is_installed(string $plugin): void {
2021          $path = core_component::get_component_directory($plugin);
2022          if (!is_readable($path . '/version.php')) {
2023              throw new \Moodle\BehatExtension\Exception\SkippedException(
2024                      'Skipping this scenario because the ' . $plugin . ' is not installed.');
2025          }
2026      }
2027  
2028      /**
2029       * Checks focus is with the given element.
2030       *
2031       * @Then /^the focused element is( not)? "(?P<node_string>(?:[^"]|\\")*)" "(?P<node_selector_string>[^"]*)"$/
2032       * @param string $not optional step verifier
2033       * @param string $nodeelement Element identifier
2034       * @param string $nodeselectortype Element type
2035       * @throws DriverException If not using JavaScript
2036       * @throws ExpectationException
2037       */
2038      public function the_focused_element_is($not, $nodeelement, $nodeselectortype) {
2039          if (!$this->running_javascript()) {
2040              throw new DriverException('Checking focus on an element requires JavaScript');
2041          }
2042  
2043          $element = $this->find($nodeselectortype, $nodeelement);
2044          $xpath = addslashes_js($element->getXpath());
2045          $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
2046                  document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
2047          $targetisfocused = $this->evaluate_script($script);
2048          if ($not == ' not') {
2049              if ($targetisfocused) {
2050                  throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
2051              }
2052          } else {
2053              if (!$targetisfocused) {
2054                  throw new ExpectationException("$nodeelement $nodeselectortype is not focused", $this->getSession());
2055              }
2056          }
2057      }
2058  
2059      /**
2060       * Checks focus is with the given element.
2061       *
2062       * @Then /^the focused element is( not)? "(?P<n>(?:[^"]|\\")*)" "(?P<ns>[^"]*)" in the "(?P<c>(?:[^"]|\\")*)" "(?P<cs>[^"]*)"$/
2063       * @param string $not string optional step verifier
2064       * @param string $element Element identifier
2065       * @param string $selectortype Element type
2066       * @param string $nodeelement Element we look in
2067       * @param string $nodeselectortype The type of selector where we look in
2068       * @throws DriverException If not using JavaScript
2069       * @throws ExpectationException
2070       */
2071      public function the_focused_element_is_in_the($not, $element, $selectortype, $nodeelement, $nodeselectortype) {
2072          if (!$this->running_javascript()) {
2073              throw new DriverException('Checking focus on an element requires JavaScript');
2074          }
2075          $element = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
2076          $xpath = addslashes_js($element->getXpath());
2077          $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
2078                  document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
2079          $targetisfocused = $this->evaluate_script($script);
2080          if ($not == ' not') {
2081              if ($targetisfocused) {
2082                  throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
2083              }
2084          } else {
2085              if (!$targetisfocused) {
2086                  throw new ExpectationException("$nodeelement $nodeselectortype is not focused", $this->getSession());
2087              }
2088          }
2089      }
2090  
2091      /**
2092       * Manually press tab key.
2093       *
2094       * @When /^I press( shift)? tab$/
2095       * @param string $shift string optional step verifier
2096       * @throws DriverException
2097       */
2098      public function i_manually_press_tab($shift = '') {
2099          if (empty($shift)) {
2100              $this->execute('behat_general::i_press_named_key', ['', 'tab']);
2101          } else {
2102              $this->execute('behat_general::i_press_named_key', ['shift', 'tab']);
2103          }
2104      }
2105  
2106      /**
2107       * Trigger click on node via javascript instead of actually clicking on it via pointer.
2108       * This function resolves the issue of nested elements.
2109       *
2110       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" skipping visibility check$/
2111       * @param string $element
2112       * @param string $selectortype
2113       */
2114      public function i_click_on_skipping_visibility_check($element, $selectortype) {
2115  
2116          // Gets the node based on the requested selector type and locator.
2117          $node = $this->get_selected_node($selectortype, $element);
2118          $this->js_trigger_click($node);
2119      }
2120  
2121      /**
2122       * Checks, that the specified element contains the specified text a certain amount of times.
2123       * When running Javascript tests it also considers that texts may be hidden.
2124       *
2125       * @Then /^I should see "(?P<elementscount_number>\d+)" occurrences of "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
2126       * @throws ElementNotFoundException
2127       * @throws ExpectationException
2128       * @param int    $elementscount How many occurrences of the element we look for.
2129       * @param string $text
2130       * @param string $element Element we look in.
2131       * @param string $selectortype The type of element where we are looking in.
2132       */
2133      public function i_should_see_occurrences_of_in_element($elementscount, $text, $element, $selectortype) {
2134  
2135          // Getting the container where the text should be found.
2136          $container = $this->get_selected_node($selectortype, $element);
2137  
2138          // Looking for all the matching nodes without any other descendant matching the
2139          // same xpath (we are using contains(., ....).
2140          $xpathliteral = behat_context_helper::escape($text);
2141          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
2142                  "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
2143  
2144          $nodes = $this->find_all('xpath', $xpath, false, $container);
2145  
2146          if ($this->running_javascript()) {
2147              $nodes = array_filter($nodes, function($node) {
2148                  return $node->isVisible();
2149              });
2150          }
2151  
2152          if ($elementscount != count($nodes)) {
2153              throw new ExpectationException('Found '.count($nodes).' elements in column. Expected '.$elementscount,
2154                      $this->getSession());
2155          }
2156      }
2157  
2158      /**
2159       * Manually press enter key.
2160       *
2161       * @When /^I press enter/
2162       * @throws DriverException
2163       */
2164      public function i_manually_press_enter() {
2165          $this->execute('behat_general::i_press_named_key', ['', 'enter']);
2166      }
2167  
2168      /**
2169       * Visit a local URL relative to the behat root.
2170       *
2171       * @When I visit :localurl
2172       *
2173       * @param string|moodle_url $localurl The URL relative to the behat_wwwroot to visit.
2174       */
2175      public function i_visit($localurl): void {
2176          $localurl = new moodle_url($localurl);
2177          $this->getSession()->visit($this->locate_path($localurl->out_as_local_url(false)));
2178      }
2179  
2180      /**
2181       * Increase the webdriver timeouts.
2182       *
2183       * This should be reset between scenarios, or can be called again to decrease the timeouts.
2184       *
2185       * @Given I mark this test as slow setting a timeout factor of :factor
2186       */
2187      public function i_mark_this_test_as_long_running(int $factor = 2): void {
2188          $this->set_test_timeout_factor($factor);
2189      }
2190  
2191      /**
2192       * Click on a dynamic tab to load its content
2193       *
2194       * @Given /^I click on the "(?P<tab_string>(?:[^"]|\\")*)" dynamic tab$/
2195       *
2196       * @param string $tabname
2197       */
2198      public function i_click_on_the_dynamic_tab(string $tabname): void {
2199          $xpath = "//*[@id='dynamictabs-tabs'][descendant::a[contains(text(), '" . $this->escape($tabname) . "')]]";
2200          $this->execute('behat_general::i_click_on_in_the',
2201              [$tabname, 'link', $xpath, 'xpath_element']);
2202      }
2203  
2204      /**
2205       * Enable an specific plugin.
2206       *
2207       * @When /^I enable "(?P<plugin_string>(?:[^"]|\\")*)" "(?P<plugintype_string>[^"]*)" plugin$/
2208       * @param string $plugin Plugin we look for
2209       * @param string $plugintype The type of the plugin
2210       */
2211      public function i_enable_plugin($plugin, $plugintype) {
2212          $class = core_plugin_manager::resolve_plugininfo_class($plugintype);
2213          $class::enable_plugin($plugin, true);
2214      }
2215  
2216      /**
2217       * Set the default text editor to the named text editor.
2218       *
2219       * @Given the default editor is set to :editor
2220       * @param string $editor
2221       * @throws ExpectationException If the specified editor is not available.
2222       */
2223      public function the_default_editor_is_set_to(string $editor): void {
2224          global $CFG;
2225  
2226          // Check if the provided editor is available.
2227          if (!array_key_exists($editor, editors_get_available())) {
2228              throw new ExpectationException(
2229                  "Unable to set the editor to {$editor} as it is not installed. The available editors are: " .
2230                      implode(', ', array_keys(editors_get_available())),
2231                  $this->getSession()
2232              );
2233          }
2234  
2235          // Make the provided editor the default one in $CFG->texteditors by
2236          // moving it to the first [editor],atto,tiny,tinymce,textarea on the list.
2237          $list = explode(',', $CFG->texteditors);
2238          array_unshift($list, $editor);
2239          $list = array_unique($list);
2240  
2241          // Set the list new list of editors.
2242          set_config('texteditors', implode(',', $list));
2243      }
2244  
2245      /**
2246       * Allow to check for minimal Moodle version.
2247       *
2248       * @Given the site is running Moodle version :minversion or higher
2249       * @param string $minversion The minimum version of Moodle required (inclusive).
2250       */
2251      public function the_site_is_running_moodle_version_or_higher(string $minversion): void {
2252          global $CFG;
2253          require_once($CFG->libdir . '/environmentlib.php');
2254  
2255          $currentversion = normalize_version(get_config('', 'release'));
2256  
2257          if (version_compare($currentversion, $minversion, '<')) {
2258              throw new Moodle\BehatExtension\Exception\SkippedException(
2259                  'Site must be running Moodle version ' . $minversion . ' or higher'
2260              );
2261          }
2262      }
2263  
2264      /**
2265       * Allow to check for maximum Moodle version.
2266       *
2267       * @Given the site is running Moodle version :maxversion or lower
2268       * @param string $maxversion The maximum version of Moodle required (inclusive).
2269       */
2270      public function the_site_is_running_moodle_version_or_lower(string $maxversion): void {
2271          global $CFG;
2272          require_once($CFG->libdir . '/environmentlib.php');
2273  
2274          $currentversion = normalize_version(get_config('', 'release'));
2275  
2276          if (version_compare($currentversion, $maxversion, '>')) {
2277              throw new Moodle\BehatExtension\Exception\SkippedException(
2278                  'Site must be running Moodle version ' . $maxversion . ' or lower'
2279              );
2280          }
2281      }
2282  
2283      /**
2284       * Check that the page title contains a given string.
2285       *
2286       * @Given the page title should contain ":title"
2287       * @param string $title The string that should be present on the page title.
2288       */
2289      public function the_page_title_should_contain(string $title): void {
2290          $session = $this->getSession();
2291          if ($this->running_javascript()) {
2292              // When running on JS, the page title can be changed via JS, so it's more reliable to get the actual page title via JS.
2293              $actualtitle = $session->evaluateScript("return document.title");
2294          } else {
2295              $titleelement = $session->getPage()->find('css', 'head title');
2296              if ($titleelement === null) {
2297                  // Throw an exception if a page title is not present on the page.
2298                  throw new ElementNotFoundException(
2299                      $this->getSession(),
2300                      '<title> element',
2301                      'css',
2302                      'head title'
2303                  );
2304              }
2305              $actualtitle = $titleelement->getText();
2306          }
2307  
2308          if (strpos($actualtitle, $title) === false) {
2309              throw new ExpectationException(
2310                  "'$title' was not found from the current page title '$actualtitle'",
2311                  $session
2312              );
2313          }
2314      }
2315  }