Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * 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 :element :selectortype in the :containerelement :containerselectortype
 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, $containerelement);
 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       * Click on the element with some modifier key pressed (alt, shift, meta or control).
 469       *
 470       * It is important to note that not all HTML elements are compatible with this step because
 471       * the webdriver limitations. For example, alt click on checkboxes with a visible label will
 472       * produce a normal checkbox click without the modifier.
 473       *
 474       * @When I :modifier click on :element :selectortype in the :nodeelement :nodeselectortype
 475       * @param string $modifier the extra modifier to press (for example, alt+shift or shift)
 476       * @param string $element Element we look for
 477       * @param string $selectortype The type of what we look for
 478       * @param string $nodeelement Element we look in
 479       * @param string $nodeselectortype The type of selector where we look in
 480       */
 481      public function i_key_click_on_in_the($modifier, $element, $selectortype, $nodeelement, $nodeselectortype) {
 482          behat_base::require_javascript_in_session($this->getSession());
 483  
 484          $key = null;
 485          switch (strtoupper(trim($modifier))) {
 486              case '':
 487                  break;
 488              case 'SHIFT':
 489                  $key = behat_keys::SHIFT;
 490                  break;
 491              case 'CTRL':
 492                  $key = behat_keys::CONTROL;
 493                  break;
 494              case 'ALT':
 495                  $key = behat_keys::ALT;
 496                  break;
 497              case 'META':
 498                  $key = behat_keys::META;
 499                  break;
 500              default:
 501                  throw new \coding_exception("Unknown modifier key '$modifier'}");
 502          }
 503  
 504          $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
 505          $this->ensure_node_is_visible($node);
 506  
 507          // KeyUP and KeyDown require the element to be displayed in the current window.
 508          $this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView();');
 509          $node->keyDown($key);
 510          $node->click();
 511          // Any click action can move the scroll. Ensure the element is still displayed.
 512          $this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView();');
 513          $node->keyUp($key);
 514      }
 515  
 516      /**
 517       * Drags and drops the specified element to the specified container. This step does not work in all the browsers, consider it experimental.
 518       *
 519       * The steps definitions calling this step as part of them should
 520       * manage the wait times by themselves as the times and when the
 521       * waits should be done depends on what is being dragged & dropper.
 522       *
 523       * @Given /^I drag "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector1_string>(?:[^"]|\\")*)" and I drop it in "(?P<container_element_string>(?:[^"]|\\")*)" "(?P<selector2_string>(?:[^"]|\\")*)"$/
 524       * @param string $element
 525       * @param string $selectortype
 526       * @param string $containerelement
 527       * @param string $containerselectortype
 528       */
 529      public function i_drag_and_i_drop_it_in($source, $sourcetype, $target, $targettype) {
 530          if (!$this->running_javascript()) {
 531              throw new DriverException('Drag and drop steps require javascript');
 532          }
 533  
 534          $source = $this->find($sourcetype, $source);
 535          $target = $this->find($targettype, $target);
 536  
 537          if (!$source->isVisible()) {
 538              throw new ExpectationException("'{$source}' '{$sourcetype}' is not visible", $this->getSession());
 539          }
 540          if (!$target->isVisible()) {
 541              throw new ExpectationException("'{$target}' '{$targettype}' is not visible", $this->getSession());
 542          }
 543  
 544          $this->getSession()->getDriver()->dragTo($source->getXpath(), $target->getXpath());
 545      }
 546  
 547      /**
 548       * Checks, that the specified element is visible. Only available in tests using Javascript.
 549       *
 550       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" should be visible$/
 551       * @throws ElementNotFoundException
 552       * @throws ExpectationException
 553       * @throws DriverException
 554       * @param string $element
 555       * @param string $selectortype
 556       * @return void
 557       */
 558      public function should_be_visible($element, $selectortype) {
 559  
 560          if (!$this->running_javascript()) {
 561              throw new DriverException('Visible checks are disabled in scenarios without Javascript support');
 562          }
 563  
 564          $node = $this->get_selected_node($selectortype, $element);
 565          if (!$node->isVisible()) {
 566              throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is not visible', $this->getSession());
 567          }
 568      }
 569  
 570      /**
 571       * Checks, that the existing element is not visible. Only available in tests using Javascript.
 572       *
 573       * As a "not" method, it's performance could not be good, but in this
 574       * case the performance is good because the element must exist,
 575       * otherwise there would be a ElementNotFoundException, also here we are
 576       * not spinning until the element is visible.
 577       *
 578       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" should not be visible$/
 579       * @throws ElementNotFoundException
 580       * @throws ExpectationException
 581       * @param string $element
 582       * @param string $selectortype
 583       * @return void
 584       */
 585      public function should_not_be_visible($element, $selectortype) {
 586  
 587          try {
 588              $this->should_be_visible($element, $selectortype);
 589          } catch (ExpectationException $e) {
 590              // All as expected.
 591              return;
 592          }
 593          throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is visible', $this->getSession());
 594      }
 595  
 596      /**
 597       * Checks, that the specified element is visible inside the specified container. Only available in tests using Javascript.
 598       *
 599       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" in the "(?P<element_container_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)" should be visible$/
 600       * @throws ElementNotFoundException
 601       * @throws DriverException
 602       * @throws ExpectationException
 603       * @param string $element Element we look for
 604       * @param string $selectortype The type of what we look for
 605       * @param string $nodeelement Element we look in
 606       * @param string $nodeselectortype The type of selector where we look in
 607       */
 608      public function in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) {
 609  
 610          if (!$this->running_javascript()) {
 611              throw new DriverException('Visible checks are disabled in scenarios without Javascript support');
 612          }
 613  
 614          $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
 615          if (!$node->isVisible()) {
 616              throw new ExpectationException(
 617                  '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is not visible',
 618                  $this->getSession()
 619              );
 620          }
 621      }
 622  
 623      /**
 624       * Checks, that the existing element is not visible inside the existing container. Only available in tests using Javascript.
 625       *
 626       * As a "not" method, it's performance could not be good, but in this
 627       * case the performance is good because the element must exist,
 628       * otherwise there would be a ElementNotFoundException, also here we are
 629       * not spinning until the element is visible.
 630       *
 631       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" in the "(?P<element_container_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)" should not be visible$/
 632       * @throws ElementNotFoundException
 633       * @throws ExpectationException
 634       * @param string $element Element we look for
 635       * @param string $selectortype The type of what we look for
 636       * @param string $nodeelement Element we look in
 637       * @param string $nodeselectortype The type of selector where we look in
 638       */
 639      public function in_the_should_not_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) {
 640  
 641          try {
 642              $this->in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype);
 643          } catch (ExpectationException $e) {
 644              // All as expected.
 645              return;
 646          }
 647          throw new ExpectationException(
 648              '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is visible',
 649              $this->getSession()
 650          );
 651      }
 652  
 653      /**
 654       * Checks, that page contains specified text. It also checks if the text is visible when running Javascript tests.
 655       *
 656       * @Then /^I should see "(?P<text_string>(?:[^"]|\\")*)"$/
 657       * @throws ExpectationException
 658       * @param string $text
 659       */
 660      public function assert_page_contains_text($text) {
 661  
 662          // Looking for all the matching nodes without any other descendant matching the
 663          // same xpath (we are using contains(., ....).
 664          $xpathliteral = behat_context_helper::escape($text);
 665          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 666              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 667  
 668          try {
 669              $nodes = $this->find_all('xpath', $xpath);
 670          } catch (ElementNotFoundException $e) {
 671              throw new ExpectationException('"' . $text . '" text was not found in the page', $this->getSession());
 672          }
 673  
 674          // If we are not running javascript we have enough with the
 675          // element existing as we can't check if it is visible.
 676          if (!$this->running_javascript()) {
 677              return;
 678          }
 679  
 680          // We spin as we don't have enough checking that the element is there, we
 681          // should also ensure that the element is visible. Using microsleep as this
 682          // is a repeated step and global performance is important.
 683          $this->spin(
 684              function($context, $args) {
 685  
 686                  foreach ($args['nodes'] as $node) {
 687                      if ($node->isVisible()) {
 688                          return true;
 689                      }
 690                  }
 691  
 692                  // If non of the nodes is visible we loop again.
 693                  throw new ExpectationException('"' . $args['text'] . '" text was found but was not visible', $context->getSession());
 694              },
 695              array('nodes' => $nodes, 'text' => $text),
 696              false,
 697              false,
 698              true
 699          );
 700  
 701      }
 702  
 703      /**
 704       * Checks, that page doesn't contain specified text. When running Javascript tests it also considers that texts may be hidden.
 705       *
 706       * @Then /^I should not see "(?P<text_string>(?:[^"]|\\")*)"$/
 707       * @throws ExpectationException
 708       * @param string $text
 709       */
 710      public function assert_page_not_contains_text($text) {
 711  
 712          // Looking for all the matching nodes without any other descendant matching the
 713          // same xpath (we are using contains(., ....).
 714          $xpathliteral = behat_context_helper::escape($text);
 715          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 716              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 717  
 718          // We should wait a while to ensure that the page is not still loading elements.
 719          // Waiting less than self::get_timeout() as we already waited for the DOM to be ready and
 720          // all JS to be executed.
 721          try {
 722              $nodes = $this->find_all('xpath', $xpath, false, false, self::get_reduced_timeout());
 723          } catch (ElementNotFoundException $e) {
 724              // All ok.
 725              return;
 726          }
 727  
 728          // If we are not running javascript we have enough with the
 729          // element existing as we can't check if it is hidden.
 730          if (!$this->running_javascript()) {
 731              throw new ExpectationException('"' . $text . '" text was found in the page', $this->getSession());
 732          }
 733  
 734          // If the element is there we should be sure that it is not visible.
 735          $this->spin(
 736              function($context, $args) {
 737  
 738                  foreach ($args['nodes'] as $node) {
 739                      // If element is removed from dom, then just exit.
 740                      try {
 741                          // If element is visible then throw exception, so we keep spinning.
 742                          if ($node->isVisible()) {
 743                              throw new ExpectationException('"' . $args['text'] . '" text was found in the page',
 744                                  $context->getSession());
 745                          }
 746                      } catch (NoSuchElementException $e) {
 747                          // Do nothing just return, as element is no more on page.
 748                          return true;
 749                      } catch (ElementNotFoundException $e) {
 750                          // Do nothing just return, as element is no more on page.
 751                          return true;
 752                      }
 753                  }
 754  
 755                  // If non of the found nodes is visible we consider that the text is not visible.
 756                  return true;
 757              },
 758              array('nodes' => $nodes, 'text' => $text),
 759              behat_base::get_reduced_timeout(),
 760              false,
 761              true
 762          );
 763      }
 764  
 765      /**
 766       * Checks, that the specified element contains the specified text. When running Javascript tests it also considers that texts may be hidden.
 767       *
 768       * @Then /^I should see "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
 769       * @throws ElementNotFoundException
 770       * @throws ExpectationException
 771       * @param string $text
 772       * @param string $element Element we look in.
 773       * @param string $selectortype The type of element where we are looking in.
 774       */
 775      public function assert_element_contains_text($text, $element, $selectortype) {
 776  
 777          // Getting the container where the text should be found.
 778          $container = $this->get_selected_node($selectortype, $element);
 779  
 780          // Looking for all the matching nodes without any other descendant matching the
 781          // same xpath (we are using contains(., ....).
 782          $xpathliteral = behat_context_helper::escape($text);
 783          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 784              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 785  
 786          // Wait until it finds the text inside the container, otherwise custom exception.
 787          try {
 788              $nodes = $this->find_all('xpath', $xpath, false, $container);
 789          } catch (ElementNotFoundException $e) {
 790              throw new ExpectationException('"' . $text . '" text was not found in the "' . $element . '" element', $this->getSession());
 791          }
 792  
 793          // If we are not running javascript we have enough with the
 794          // element existing as we can't check if it is visible.
 795          if (!$this->running_javascript()) {
 796              return;
 797          }
 798  
 799          // We also check the element visibility when running JS tests. Using microsleep as this
 800          // is a repeated step and global performance is important.
 801          $this->spin(
 802              function($context, $args) {
 803  
 804                  foreach ($args['nodes'] as $node) {
 805                      if ($node->isVisible()) {
 806                          return true;
 807                      }
 808                  }
 809  
 810                  throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element but was not visible', $context->getSession());
 811              },
 812              array('nodes' => $nodes, 'text' => $text, 'element' => $element),
 813              false,
 814              false,
 815              true
 816          );
 817      }
 818  
 819      /**
 820       * Checks, that the specified element does not contain the specified text. When running Javascript tests it also considers that texts may be hidden.
 821       *
 822       * @Then /^I should not see "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
 823       * @throws ElementNotFoundException
 824       * @throws ExpectationException
 825       * @param string $text
 826       * @param string $element Element we look in.
 827       * @param string $selectortype The type of element where we are looking in.
 828       */
 829      public function assert_element_not_contains_text($text, $element, $selectortype) {
 830  
 831          // Getting the container where the text should be found.
 832          $container = $this->get_selected_node($selectortype, $element);
 833  
 834          // Looking for all the matching nodes without any other descendant matching the
 835          // same xpath (we are using contains(., ....).
 836          $xpathliteral = behat_context_helper::escape($text);
 837          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
 838              "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
 839  
 840          // We should wait a while to ensure that the page is not still loading elements.
 841          // Giving preference to the reliability of the results rather than to the performance.
 842          try {
 843              $nodes = $this->find_all('xpath', $xpath, false, $container, self::get_reduced_timeout());
 844          } catch (ElementNotFoundException $e) {
 845              // All ok.
 846              return;
 847          }
 848  
 849          // If we are not running javascript we have enough with the
 850          // element not being found as we can't check if it is visible.
 851          if (!$this->running_javascript()) {
 852              throw new ExpectationException('"' . $text . '" text was found in the "' . $element . '" element', $this->getSession());
 853          }
 854  
 855          // We need to ensure all the found nodes are hidden.
 856          $this->spin(
 857              function($context, $args) {
 858  
 859                  foreach ($args['nodes'] as $node) {
 860                      if ($node->isVisible()) {
 861                          throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element', $context->getSession());
 862                      }
 863                  }
 864  
 865                  // If all the found nodes are hidden we are happy.
 866                  return true;
 867              },
 868              array('nodes' => $nodes, 'text' => $text, 'element' => $element),
 869              behat_base::get_reduced_timeout(),
 870              false,
 871              true
 872          );
 873      }
 874  
 875      /**
 876       * Checks, that the first specified element appears before the second one.
 877       *
 878       * @Then :preelement :preselectortype should appear before :postelement :postselectortype
 879       * @Then :preelement :preselectortype should appear before :postelement :postselectortype in the :containerelement :containerselectortype
 880       * @throws ExpectationException
 881       * @param string $preelement The locator of the preceding element
 882       * @param string $preselectortype The selector type of the preceding element
 883       * @param string $postelement The locator of the latest element
 884       * @param string $postselectortype The selector type of the latest element
 885       * @param string $containerelement
 886       * @param string $containerselectortype
 887       */
 888      public function should_appear_before(
 889          string $preelement,
 890          string $preselectortype,
 891          string $postelement,
 892          string $postselectortype,
 893          ?string $containerelement = null,
 894          ?string $containerselectortype = null
 895      ) {
 896          $msg = "'{$preelement}' '{$preselectortype}' does not appear before '{$postelement}' '{$postselectortype}'";
 897          $this->check_element_order(
 898              $containerelement,
 899              $containerselectortype,
 900              $preelement,
 901              $preselectortype,
 902              $postelement,
 903              $postselectortype,
 904              $msg
 905          );
 906      }
 907  
 908      /**
 909       * Checks, that the first specified element appears after the second one.
 910       *
 911       * @Then :postelement :postselectortype should appear after :preelement :preselectortype
 912       * @Then :postelement :postselectortype should appear after :preelement :preselectortype in the :containerelement :containerselectortype
 913       * @throws ExpectationException
 914       * @param string $postelement The locator of the latest element
 915       * @param string $postselectortype The selector type of the latest element
 916       * @param string $preelement The locator of the preceding element
 917       * @param string $preselectortype The selector type of the preceding element
 918       * @param string $containerelement
 919       * @param string $containerselectortype
 920       */
 921      public function should_appear_after(
 922          string $postelement,
 923          string $postselectortype,
 924          string $preelement,
 925          string $preselectortype,
 926          ?string $containerelement = null,
 927          ?string $containerselectortype = null
 928      ) {
 929          $msg = "'{$postelement}' '{$postselectortype}' does not appear after '{$preelement}' '{$preselectortype}'";
 930          $this->check_element_order(
 931              $containerelement,
 932              $containerselectortype,
 933              $preelement,
 934              $preselectortype,
 935              $postelement,
 936              $postselectortype,
 937              $msg
 938          );
 939      }
 940  
 941      /**
 942       * Shared code to check whether an element is before or after another one.
 943       *
 944       * @param string $containerelement
 945       * @param string $containerselectortype
 946       * @param string $preelement The locator of the preceding element
 947       * @param string $preselectortype The locator of the preceding element
 948       * @param string $postelement The locator of the following element
 949       * @param string $postselectortype The selector type of the following element
 950       * @param string $msg Message to output if this fails
 951       */
 952      protected function check_element_order(
 953          ?string $containerelement,
 954          ?string $containerselectortype,
 955          string $preelement,
 956          string $preselectortype,
 957          string $postelement,
 958          string $postselectortype,
 959          string $msg
 960      ) {
 961          $containernode = false;
 962          if ($containerselectortype && $containerelement) {
 963              // Get the container node.
 964              $containernode = $this->get_selected_node($containerselectortype, $containerelement);
 965              $msg .= " in the '{$containerelement}' '{$containerselectortype}'";
 966          }
 967  
 968          list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement);
 969          list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement);
 970  
 971          $newlines = [
 972              "\r\n",
 973              "\r",
 974              "\n",
 975          ];
 976          $prexpath = str_replace($newlines, ' ', $this->find($preselector, $prelocator, false, $containernode)->getXpath());
 977          $postxpath = str_replace($newlines, ' ', $this->find($postselector, $postlocator, false, $containernode)->getXpath());
 978  
 979          if ($this->running_javascript()) {
 980              // The xpath to do this was running really slowly on certain Chrome versions so we are using
 981              // this DOM method instead.
 982              $js = <<<EOF
 983  (function() {
 984      var a = document.evaluate("{$prexpath}", document, null, XPathResult.ANY_TYPE, null).iterateNext();
 985      var b = document.evaluate("{$postxpath}", document, null, XPathResult.ANY_TYPE, null).iterateNext();
 986      return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
 987  })()
 988  EOF;
 989              $ok = $this->evaluate_script($js);
 990          } else {
 991  
 992              // Using following xpath axe to find it.
 993              $xpath = "{$prexpath}/following::*[contains(., {$postxpath})]";
 994              $ok = $this->getSession()->getDriver()->find($xpath);
 995          }
 996  
 997          if (!$ok) {
 998              throw new ExpectationException($msg, $this->getSession());
 999          }
1000      }
1001  
1002      /**
1003       * Checks, that element of specified type is disabled.
1004       *
1005       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be disabled$/
1006       * @throws ExpectationException Thrown by behat_base::find
1007       * @param string $element Element we look in
1008       * @param string $selectortype The type of element where we are looking in.
1009       */
1010      public function the_element_should_be_disabled($element, $selectortype) {
1011          $this->the_attribute_of_should_be_set("disabled", $element, $selectortype, false);
1012      }
1013  
1014      /**
1015       * Checks, that element of specified type is enabled.
1016       *
1017       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be enabled$/
1018       * @throws ExpectationException Thrown by behat_base::find
1019       * @param string $element Element we look on
1020       * @param string $selectortype The type of where we look
1021       */
1022      public function the_element_should_be_enabled($element, $selectortype) {
1023          $this->the_attribute_of_should_be_set("disabled", $element, $selectortype, true);
1024      }
1025  
1026      /**
1027       * Checks the provided element and selector type are readonly on the current page.
1028       *
1029       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be readonly$/
1030       * @throws ExpectationException Thrown by behat_base::find
1031       * @param string $element Element we look in
1032       * @param string $selectortype The type of element where we are looking in.
1033       */
1034      public function the_element_should_be_readonly($element, $selectortype) {
1035          $this->the_attribute_of_should_be_set("readonly", $element, $selectortype, false);
1036      }
1037  
1038      /**
1039       * Checks the provided element and selector type are not readonly on the current page.
1040       *
1041       * @Then /^the "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not be readonly$/
1042       * @throws ExpectationException Thrown by behat_base::find
1043       * @param string $element Element we look in
1044       * @param string $selectortype The type of element where we are looking in.
1045       */
1046      public function the_element_should_not_be_readonly($element, $selectortype) {
1047          $this->the_attribute_of_should_be_set("readonly", $element, $selectortype, true);
1048      }
1049  
1050      /**
1051       * Checks the provided element and selector type exists in the current page.
1052       *
1053       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1054       *
1055       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exist$/
1056       * @throws ElementNotFoundException Thrown by behat_base::find
1057       * @param string $element The locator of the specified selector
1058       * @param string $selectortype The selector type
1059       */
1060      public function should_exist($element, $selectortype) {
1061          // Will throw an ElementNotFoundException if it does not exist.
1062          $this->find($selectortype, $element);
1063      }
1064  
1065      /**
1066       * Checks that the provided element and selector type not exists in the current page.
1067       *
1068       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1069       *
1070       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exist$/
1071       * @throws ExpectationException
1072       * @param string $element The locator of the specified selector
1073       * @param string $selectortype The selector type
1074       */
1075      public function should_not_exist($element, $selectortype) {
1076          // Will throw an ElementNotFoundException if it does not exist, but, actually it should not exist, so we try &
1077          // catch it.
1078          try {
1079              // The exception does not really matter as we will catch it and will never "explode".
1080              $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $element);
1081  
1082              // Using the spin method as we want a reduced timeout but there is no need for a 0.1 seconds interval
1083              // because in the optimistic case we will timeout.
1084              // If all goes good it will throw an ElementNotFoundExceptionn that we will catch.
1085              $this->find($selectortype, $element, $exception, false, behat_base::get_reduced_timeout());
1086          } catch (ElementNotFoundException $e) {
1087              // We expect the element to not be found.
1088              return;
1089          }
1090  
1091          // The element was found and should not have been. Throw an exception.
1092          throw new ExpectationException("The '{$element}' '{$selectortype}' exists in the current page", $this->getSession());
1093      }
1094  
1095      /**
1096       * Ensure that edit mode is (not) available on the current page.
1097       *
1098       * @Then edit mode should be available on the current page
1099       * @Then edit mode should :not be available on the current page
1100       * @param bool $not
1101       */
1102      public function edit_mode_should_be_available(bool $not = false): void {
1103          $isavailable = $this->is_edit_mode_available();
1104          $shouldbeavailable = empty($not);
1105  
1106          if ($isavailable && !$shouldbeavailable) {
1107              throw new ExpectationException("Edit mode is available and should not be", $this->getSession());
1108          } else if ($shouldbeavailable && !$isavailable) {
1109              throw new ExpectationException("Edit mode is not available and should be", $this->getSession());
1110          }
1111      }
1112  
1113      /**
1114       * Check whether edit mode is available on the current page.
1115       *
1116       * @return bool
1117       */
1118      public function is_edit_mode_available(): bool {
1119          // If the course is already in editing mode then it will have the class 'editing' on the body.
1120          // This is a 'cheap' way of telling if the course is in editing mode and therefore if edit mode is available.
1121          $body = $this->find('css', 'body');
1122          if ($body->hasClass('editing')) {
1123              return true;
1124          }
1125  
1126          try {
1127              $this->find('field', get_string('editmode'), false, false, 0);
1128              return true;
1129          } catch (ElementNotFoundException $e) {
1130              return false;
1131          }
1132      }
1133  
1134      /**
1135       * This step triggers cron like a user would do going to admin/cron.php.
1136       *
1137       * @Given /^I trigger cron$/
1138       */
1139      public function i_trigger_cron() {
1140          $this->execute('behat_general::i_visit', ['/admin/cron.php']);
1141      }
1142  
1143      /**
1144       * Runs a scheduled task immediately, given full class name.
1145       *
1146       * This is faster and more reliable than running cron (running cron won't
1147       * work more than once in the same test, for instance). However it is
1148       * a little less 'realistic'.
1149       *
1150       * While the task is running, we suppress mtrace output because it makes
1151       * the Behat result look ugly.
1152       *
1153       * Note: Most of the code relating to running a task is based on
1154       * admin/cli/scheduled_task.php.
1155       *
1156       * @Given /^I run the scheduled task "(?P<task_name>[^"]+)"$/
1157       * @param string $taskname Name of task e.g. 'mod_whatever\task\do_something'
1158       */
1159      public function i_run_the_scheduled_task($taskname) {
1160          $task = \core\task\manager::get_scheduled_task($taskname);
1161          if (!$task) {
1162              throw new DriverException('The "' . $taskname . '" scheduled task does not exist');
1163          }
1164  
1165          // Do setup for cron task.
1166          raise_memory_limit(MEMORY_EXTRA);
1167          \core\cron::setup_user();
1168  
1169          // Get lock.
1170          $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
1171          if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
1172              throw new DriverException('Unable to obtain core_cron lock for scheduled task');
1173          }
1174          if (!$lock = $cronlockfactory->get_lock('\\' . get_class($task), 10)) {
1175              $cronlock->release();
1176              throw new DriverException('Unable to obtain task lock for scheduled task');
1177          }
1178          $task->set_lock($lock);
1179          if (!$task->is_blocking()) {
1180              $cronlock->release();
1181          } else {
1182              $task->set_cron_lock($cronlock);
1183          }
1184  
1185          try {
1186              // Prepare the renderer.
1187              \core\cron::prepare_core_renderer();
1188  
1189              // Discard task output as not appropriate for Behat output!
1190              ob_start();
1191              $task->execute();
1192              ob_end_clean();
1193  
1194              // Restore the previous renderer.
1195              \core\cron::prepare_core_renderer(true);
1196  
1197              // Mark task complete.
1198              \core\task\manager::scheduled_task_complete($task);
1199          } catch (Exception $e) {
1200              // Restore the previous renderer.
1201              \core\cron::prepare_core_renderer(true);
1202  
1203              // Mark task failed and throw exception.
1204              \core\task\manager::scheduled_task_failed($task);
1205  
1206              throw new DriverException('The "' . $taskname . '" scheduled task failed', 0, $e);
1207          }
1208      }
1209  
1210      /**
1211       * Runs all ad-hoc tasks in the queue.
1212       *
1213       * This is faster and more reliable than running cron (running cron won't
1214       * work more than once in the same test, for instance). However it is
1215       * a little less 'realistic'.
1216       *
1217       * While the task is running, we suppress mtrace output because it makes
1218       * the Behat result look ugly.
1219       *
1220       * @Given /^I run all adhoc tasks$/
1221       * @throws DriverException
1222       */
1223      public function i_run_all_adhoc_tasks() {
1224          global $DB;
1225  
1226          // Do setup for cron task.
1227          \core\cron::setup_user();
1228  
1229          // Discard task output as not appropriate for Behat output!
1230          ob_start();
1231  
1232          // Run all tasks which have a scheduled runtime of before now.
1233          $timenow = time();
1234  
1235          while (!\core\task\manager::static_caches_cleared_since($timenow) &&
1236                  $task = \core\task\manager::get_next_adhoc_task($timenow)) {
1237              // Clean the output buffer between tasks.
1238              ob_clean();
1239  
1240              // Run the task.
1241              \core\cron::run_inner_adhoc_task($task);
1242  
1243              // Check whether the task record still exists.
1244              // If a task was successful it will be removed.
1245              // If it failed then it will still exist.
1246              if ($DB->record_exists('task_adhoc', ['id' => $task->get_id()])) {
1247                  // End ouptut buffering and flush the current buffer.
1248                  // This should be from just the current task.
1249                  ob_end_flush();
1250  
1251                  throw new DriverException('An adhoc task failed', 0);
1252              }
1253          }
1254          ob_end_clean();
1255      }
1256  
1257      /**
1258       * Checks that an element and selector type exists in another element and selector type on the current page.
1259       *
1260       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1261       *
1262       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exist in the "(?P<element2_string>(?:[^"]|\\")*)" "(?P<selector2_string>[^"]*)"$/
1263       * @throws ElementNotFoundException Thrown by behat_base::find
1264       * @param string $element The locator of the specified selector
1265       * @param string $selectortype The selector type
1266       * @param NodeElement|string $containerelement The locator of the container selector
1267       * @param string $containerselectortype The container selector type
1268       */
1269      public function should_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) {
1270          // Will throw an ElementNotFoundException if it does not exist.
1271          $this->get_node_in_container($selectortype, $element, $containerselectortype, $containerelement);
1272      }
1273  
1274      /**
1275       * Checks that an element and selector type does not exist in another element and selector type on the current page.
1276       *
1277       * This step is for advanced users, use it if you don't find anything else suitable for what you need.
1278       *
1279       * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exist in the "(?P<element2_string>(?:[^"]|\\")*)" "(?P<selector2_string>[^"]*)"$/
1280       * @throws ExpectationException
1281       * @param string $element The locator of the specified selector
1282       * @param string $selectortype The selector type
1283       * @param NodeElement|string $containerelement The locator of the container selector
1284       * @param string $containerselectortype The container selector type
1285       */
1286      public function should_not_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) {
1287          // Get the container node.
1288          $containernode = $this->find($containerselectortype, $containerelement);
1289  
1290          // Will throw an ElementNotFoundException if it does not exist, but, actually it should not exist, so we try &
1291          // catch it.
1292          try {
1293              // Looks for the requested node inside the container node.
1294              $this->find($selectortype, $element, false, $containernode, behat_base::get_reduced_timeout());
1295          } catch (ElementNotFoundException $e) {
1296              // We expect the element to not be found.
1297              return;
1298          }
1299  
1300          // The element was found and should not have been. Throw an exception.
1301          $elementdescription = $this->get_selector_description($selectortype, $element);
1302          $containerdescription = $this->get_selector_description($containerselectortype, $containerelement);
1303          throw new ExpectationException(
1304              "The {$elementdescription} exists in the {$containerdescription}",
1305              $this->getSession()
1306          );
1307      }
1308  
1309      /**
1310       * Change browser window size
1311       *
1312       * Allowed sizes:
1313       * - mobile: 425x750
1314       * - tablet: 768x1024
1315       * - small: 1024x768
1316       * - medium: 1366x768
1317       * - large: 2560x1600
1318       * - custom: widthxheight
1319       *
1320       * Example: I change window size to "small" or I change window size to "1024x768"
1321       * or I change viewport size to "800x600". The viewport option is useful to guarantee that the
1322       * browser window has same viewport size even when you run Behat on multiple operating systems.
1323       *
1324       * @throws ExpectationException
1325       * @Then /^I change (window|viewport) size to "(mobile|tablet|small|medium|large|\d+x\d+)"$/
1326       * @Then /^I change the (window|viewport) size to "(mobile|tablet|small|medium|large|\d+x\d+)"$/
1327       * @param string $windowsize size of the window (mobile|tablet|small|medium|large|wxh).
1328       */
1329      public function i_change_window_size_to($windowviewport, $windowsize) {
1330          $this->resize_window($windowsize, $windowviewport === 'viewport');
1331      }
1332  
1333      /**
1334       * Checks whether there the specified attribute is set or not.
1335       *
1336       * @Then the :attribute attribute of :element :selectortype should be set
1337       * @Then the :attribute attribute of :element :selectortype should :not be set
1338       *
1339       * @throws ExpectationException
1340       * @param string $attribute Name of attribute
1341       * @param string $element The locator of the specified selector
1342       * @param string $selectortype The selector type
1343       * @param string $not
1344       */
1345      public function the_attribute_of_should_be_set($attribute, $element, $selectortype, $not = null) {
1346          // Get the container node (exception if it doesn't exist).
1347          $containernode = $this->get_selected_node($selectortype, $element);
1348          $hasattribute = $containernode->hasAttribute($attribute);
1349  
1350          if ($not && $hasattribute) {
1351              $value = $containernode->getAttribute($attribute);
1352              // Should not be set but is.
1353              throw new ExpectationException(
1354                  "The attribute \"{$attribute}\" should not be set but has a value of '{$value}'",
1355                  $this->getSession()
1356              );
1357          } else if (!$not && !$hasattribute) {
1358              // Should be set but is not.
1359              throw new ExpectationException(
1360                  "The attribute \"{$attribute}\" should be set but is not",
1361                  $this->getSession()
1362              );
1363          }
1364      }
1365  
1366      /**
1367       * Checks whether there is an attribute on the given element that contains the specified text.
1368       *
1369       * @Then /^the "(?P<attribute_string>[^"]*)" attribute of "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should contain "(?P<text_string>(?:[^"]|\\")*)"$/
1370       * @throws ExpectationException
1371       * @param string $attribute Name of attribute
1372       * @param string $element The locator of the specified selector
1373       * @param string $selectortype The selector type
1374       * @param string $text Expected substring
1375       */
1376      public function the_attribute_of_should_contain($attribute, $element, $selectortype, $text) {
1377          // Get the container node (exception if it doesn't exist).
1378          $containernode = $this->get_selected_node($selectortype, $element);
1379          $value = $containernode->getAttribute($attribute);
1380          if ($value == null) {
1381              throw new ExpectationException('The attribute "' . $attribute. '" does not exist',
1382                      $this->getSession());
1383          } else if (strpos($value, $text) === false) {
1384              throw new ExpectationException('The attribute "' . $attribute .
1385                      '" does not contain "' . $text . '" (actual value: "' . $value . '")',
1386                      $this->getSession());
1387          }
1388      }
1389  
1390      /**
1391       * Checks that the attribute on the given element does not contain the specified text.
1392       *
1393       * @Then /^the "(?P<attribute_string>[^"]*)" attribute of "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not contain "(?P<text_string>(?:[^"]|\\")*)"$/
1394       * @throws ExpectationException
1395       * @param string $attribute Name of attribute
1396       * @param string $element The locator of the specified selector
1397       * @param string $selectortype The selector type
1398       * @param string $text Expected substring
1399       */
1400      public function the_attribute_of_should_not_contain($attribute, $element, $selectortype, $text) {
1401          // Get the container node (exception if it doesn't exist).
1402          $containernode = $this->get_selected_node($selectortype, $element);
1403          $value = $containernode->getAttribute($attribute);
1404          if ($value == null) {
1405              throw new ExpectationException('The attribute "' . $attribute. '" does not exist',
1406                      $this->getSession());
1407          } else if (strpos($value, $text) !== false) {
1408              throw new ExpectationException('The attribute "' . $attribute .
1409                      '" contains "' . $text . '" (value: "' . $value . '")',
1410                      $this->getSession());
1411          }
1412      }
1413  
1414      /**
1415       * Checks the provided value exists in specific row/column of table.
1416       *
1417       * @Then /^"(?P<row_string>[^"]*)" row "(?P<column_string>[^"]*)" column of "(?P<table_string>[^"]*)" table should contain "(?P<value_string>[^"]*)"$/
1418       * @throws ElementNotFoundException
1419       * @param string $row row text which will be looked in.
1420       * @param string $column column text to search (or numeric value for the column position)
1421       * @param string $table table id/class/caption
1422       * @param string $value text to check.
1423       */
1424      public function row_column_of_table_should_contain($row, $column, $table, $value) {
1425          $tablenode = $this->get_selected_node('table', $table);
1426          $tablexpath = $tablenode->getXpath();
1427  
1428          $rowliteral = behat_context_helper::escape($row);
1429          $valueliteral = behat_context_helper::escape($value);
1430          $columnliteral = behat_context_helper::escape($column);
1431  
1432          if (preg_match('/^-?(\d+)-?$/', $column, $columnasnumber)) {
1433              // Column indicated as a number, just use it as position of the column.
1434              $columnpositionxpath = "/child::*[position() = {$columnasnumber[1]}]";
1435          } else {
1436              // Header can be in thead or tbody (first row), following xpath should work.
1437              $theadheaderxpath = "thead/tr[1]/th[(normalize-space(.)=" . $columnliteral . " or a[normalize-space(text())=" .
1438                      $columnliteral . "] or div[normalize-space(text())=" . $columnliteral . "])]";
1439              $tbodyheaderxpath = "tbody/tr[1]/td[(normalize-space(.)=" . $columnliteral . " or a[normalize-space(text())=" .
1440                      $columnliteral . "] or div[normalize-space(text())=" . $columnliteral . "])]";
1441  
1442              // Check if column exists.
1443              $columnheaderxpath = $tablexpath . "[" . $theadheaderxpath . " | " . $tbodyheaderxpath . "]";
1444              $columnheader = $this->getSession()->getDriver()->find($columnheaderxpath);
1445              if (empty($columnheader)) {
1446                  $columnexceptionmsg = $column . '" in table "' . $table . '"';
1447                  throw new ElementNotFoundException($this->getSession(), "\n$columnheaderxpath\n\n".'Column', null, $columnexceptionmsg);
1448              }
1449              // Following conditions were considered before finding column count.
1450              // 1. Table header can be in thead/tr/th or tbody/tr/td[1].
1451              // 2. First column can have th (Gradebook -> user report), so having lenient sibling check.
1452              $columnpositionxpath = "/child::*[position() = count(" . $tablexpath . "/" . $theadheaderxpath .
1453                  "/preceding-sibling::*) + 1]";
1454          }
1455  
1456          // Check if value exists in specific row/column.
1457          // Get row xpath.
1458          // Some drivers make XPath relative to the current context, so use descendant.
1459          $rowxpath = $tablexpath . "/tbody/tr[descendant::*[@class='rowtitle'][normalize-space(.)=" . $rowliteral . "] | " . "
1460              descendant::th[normalize-space(.)=" . $rowliteral . "] | descendant::td[normalize-space(.)=" . $rowliteral . "]]";
1461  
1462          $columnvaluexpath = $rowxpath . $columnpositionxpath . "[contains(normalize-space(.)," . $valueliteral . ")]";
1463  
1464          // Looks for the requested node inside the container node.
1465          $coumnnode = $this->getSession()->getDriver()->find($columnvaluexpath);
1466          if (empty($coumnnode)) {
1467              $locatorexceptionmsg = $value . '" in "' . $row . '" row with column "' . $column;
1468              throw new ElementNotFoundException($this->getSession(), "\n$columnvaluexpath\n\n".'Column value', null, $locatorexceptionmsg);
1469          }
1470      }
1471  
1472      /**
1473       * Checks the provided value should not exist in specific row/column of table.
1474       *
1475       * @Then /^"(?P<row_string>[^"]*)" row "(?P<column_string>[^"]*)" column of "(?P<table_string>[^"]*)" table should not contain "(?P<value_string>[^"]*)"$/
1476       * @throws ElementNotFoundException
1477       * @param string $row row text which will be looked in.
1478       * @param string $column column text to search
1479       * @param string $table table id/class/caption
1480       * @param string $value text to check.
1481       */
1482      public function row_column_of_table_should_not_contain($row, $column, $table, $value) {
1483          try {
1484              $this->row_column_of_table_should_contain($row, $column, $table, $value);
1485          } catch (ElementNotFoundException $e) {
1486              // Table row/column doesn't contain this value. Nothing to do.
1487              return;
1488          }
1489          // Throw exception if found.
1490          throw new ExpectationException(
1491              '"' . $column . '" with value "' . $value . '" is present in "' . $row . '"  row for table "' . $table . '"',
1492              $this->getSession()
1493          );
1494      }
1495  
1496      /**
1497       * Checks that the provided value exist in table.
1498       *
1499       * First row may contain column headers or numeric indexes of the columns
1500       * (syntax -1- is also considered to be column index). Column indexes are
1501       * useful in case of multirow headers and/or presence of cells with colspan.
1502       *
1503       * @Then /^the following should exist in the "(?P<table_string>[^"]*)" table:$/
1504       * @throws ExpectationException
1505       * @param string $table name of table
1506       * @param TableNode $data table with first row as header and following values
1507       *        | Header 1 | Header 2 | Header 3 |
1508       *        | Value 1 | Value 2 | Value 3|
1509       */
1510      public function following_should_exist_in_the_table($table, TableNode $data) {
1511          $datahash = $data->getHash();
1512  
1513          foreach ($datahash as $row) {
1514              $firstcell = null;
1515              foreach ($row as $column => $value) {
1516                  if ($firstcell === null) {
1517                      $firstcell = $value;
1518                  } else {
1519                      $this->row_column_of_table_should_contain($firstcell, $column, $table, $value);
1520                  }
1521              }
1522          }
1523      }
1524  
1525      /**
1526       * Checks that the provided values do not exist in a table.
1527       *
1528       * @Then /^the following should not exist in the "(?P<table_string>[^"]*)" table:$/
1529       * @throws ExpectationException
1530       * @param string $table name of table
1531       * @param TableNode $data table with first row as header and following values
1532       *        | Header 1 | Header 2 | Header 3 |
1533       *        | Value 1 | Value 2 | Value 3|
1534       */
1535      public function following_should_not_exist_in_the_table($table, TableNode $data) {
1536          $datahash = $data->getHash();
1537  
1538          foreach ($datahash as $value) {
1539              $row = array_shift($value);
1540              foreach ($value as $column => $value) {
1541                  try {
1542                      $this->row_column_of_table_should_contain($row, $column, $table, $value);
1543                      // Throw exception if found.
1544                  } catch (ElementNotFoundException $e) {
1545                      // Table row/column doesn't contain this value. Nothing to do.
1546                      continue;
1547                  }
1548                  throw new ExpectationException('"' . $column . '" with value "' . $value . '" is present in "' .
1549                      $row . '"  row for table "' . $table . '"', $this->getSession()
1550                  );
1551              }
1552          }
1553      }
1554  
1555      /**
1556       * Given the text of a link, download the linked file and return the contents.
1557       *
1558       * This is a helper method used by {@link following_should_download_bytes()}
1559       * and {@link following_should_download_between_and_bytes()}
1560       *
1561       * @param string $link the text of the link.
1562       * @return string the content of the downloaded file.
1563       */
1564      public function download_file_from_link($link) {
1565          // Find the link.
1566          $linknode = $this->find_link($link);
1567          $this->ensure_node_is_visible($linknode);
1568  
1569          // Get the href and check it.
1570          $url = $linknode->getAttribute('href');
1571          if (!$url) {
1572              throw new ExpectationException('Download link does not have href attribute',
1573                      $this->getSession());
1574          }
1575          if (!preg_match('~^https?://~', $url)) {
1576              throw new ExpectationException('Download link not an absolute URL: ' . $url,
1577                      $this->getSession());
1578          }
1579  
1580          // Download the URL and check the size.
1581          $session = $this->getSession()->getCookie('MoodleSession');
1582          return download_file_content($url, array('Cookie' => 'MoodleSession=' . $session));
1583      }
1584  
1585      /**
1586       * Downloads the file from a link on the page and checks the size.
1587       *
1588       * Only works if the link has an href attribute. Javascript downloads are
1589       * not supported. Currently, the href must be an absolute URL.
1590       *
1591       * @Then /^following "(?P<link_string>[^"]*)" should download "(?P<expected_bytes>\d+)" bytes$/
1592       * @throws ExpectationException
1593       * @param string $link the text of the link.
1594       * @param number $expectedsize the expected file size in bytes.
1595       */
1596      public function following_should_download_bytes($link, $expectedsize) {
1597          $exception = new ExpectationException('Error while downloading data from ' . $link, $this->getSession());
1598  
1599          // It will stop spinning once file is downloaded or time out.
1600          $result = $this->spin(
1601              function($context, $args) {
1602                  $link = $args['link'];
1603                  return $this->download_file_from_link($link);
1604              },
1605              array('link' => $link),
1606              behat_base::get_extended_timeout(),
1607              $exception
1608          );
1609  
1610          // Check download size.
1611          $actualsize = (int)strlen($result);
1612          if ($actualsize !== (int)$expectedsize) {
1613              throw new ExpectationException('Downloaded data was ' . $actualsize .
1614                      ' bytes, expecting ' . $expectedsize, $this->getSession());
1615          }
1616      }
1617  
1618      /**
1619       * Downloads the file from a link on the page and checks the size is in a given range.
1620       *
1621       * Only works if the link has an href attribute. Javascript downloads are
1622       * not supported. Currently, the href must be an absolute URL.
1623       *
1624       * The range includes the endpoints. That is, a 10 byte file in considered to
1625       * be between "5" and "10" bytes, and between "10" and "20" bytes.
1626       *
1627       * @Then /^following "(?P<link_string>[^"]*)" should download between "(?P<min_bytes>\d+)" and "(?P<max_bytes>\d+)" bytes$/
1628       * @throws ExpectationException
1629       * @param string $link the text of the link.
1630       * @param number $minexpectedsize the minimum expected file size in bytes.
1631       * @param number $maxexpectedsize the maximum expected file size in bytes.
1632       */
1633      public function following_should_download_between_and_bytes($link, $minexpectedsize, $maxexpectedsize) {
1634          // If the minimum is greater than the maximum then swap the values.
1635          if ((int)$minexpectedsize > (int)$maxexpectedsize) {
1636              list($minexpectedsize, $maxexpectedsize) = array($maxexpectedsize, $minexpectedsize);
1637          }
1638  
1639          $exception = new ExpectationException('Error while downloading data from ' . $link, $this->getSession());
1640  
1641          // It will stop spinning once file is downloaded or time out.
1642          $result = $this->spin(
1643              function($context, $args) {
1644                  $link = $args['link'];
1645  
1646                  return $this->download_file_from_link($link);
1647              },
1648              array('link' => $link),
1649              behat_base::get_extended_timeout(),
1650              $exception
1651          );
1652  
1653          // Check download size.
1654          $actualsize = (int)strlen($result);
1655          if ($actualsize < $minexpectedsize || $actualsize > $maxexpectedsize) {
1656              throw new ExpectationException('Downloaded data was ' . $actualsize .
1657                      ' bytes, expecting between ' . $minexpectedsize . ' and ' .
1658                      $maxexpectedsize, $this->getSession());
1659          }
1660      }
1661  
1662      /**
1663       * Checks that the image on the page is the same as one of the fixture files
1664       *
1665       * @Then /^the image at "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should be identical to "(?P<filepath_string>(?:[^"]|\\")*)"$/
1666       * @throws ExpectationException
1667       * @param string $element The locator of the image
1668       * @param string $selectortype The selector type
1669       * @param string $filepath path to the fixture file
1670       */
1671      public function the_image_at_should_be_identical_to($element, $selectortype, $filepath) {
1672          global $CFG;
1673  
1674          // Get the container node (exception if it doesn't exist).
1675          $containernode = $this->get_selected_node($selectortype, $element);
1676          $url = $containernode->getAttribute('src');
1677          if ($url == null) {
1678              throw new ExpectationException('Element does not have src attribute',
1679                  $this->getSession());
1680          }
1681          $session = $this->getSession()->getCookie('MoodleSession');
1682          $content = download_file_content($url, array('Cookie' => 'MoodleSession=' . $session));
1683  
1684          // Get the content of the fixture file.
1685          // Replace 'admin/' if it is in start of path with $CFG->admin .
1686          if (substr($filepath, 0, 6) === 'admin/') {
1687              $filepath = $CFG->admin . DIRECTORY_SEPARATOR . substr($filepath, 6);
1688          }
1689          $filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath);
1690          $filepath = $CFG->dirroot . DIRECTORY_SEPARATOR . $filepath;
1691          if (!is_readable($filepath)) {
1692              throw new ExpectationException('The file to compare to does not exist.', $this->getSession());
1693          }
1694          $expectedcontent = file_get_contents($filepath);
1695  
1696          if ($content !== $expectedcontent) {
1697              throw new ExpectationException('Image is not identical to the fixture. Received ' .
1698              strlen($content) . ' bytes and expected ' . strlen($expectedcontent) . ' bytes', $this->getSession());
1699          }
1700      }
1701  
1702      /**
1703       * Prepare to detect whether or not a new page has loaded (or the same page reloaded) some time in the future.
1704       *
1705       * @Given /^I start watching to see if a new page loads$/
1706       */
1707      public function i_start_watching_to_see_if_a_new_page_loads() {
1708          if (!$this->running_javascript()) {
1709              throw new DriverException('Page load detection requires JavaScript.');
1710          }
1711  
1712          $session = $this->getSession();
1713  
1714          if ($this->pageloaddetectionrunning || $session->getPage()->find('xpath', $this->get_page_load_xpath())) {
1715              // If we find this node at this point we are already watching for a reload and the behat steps
1716              // are out of order. We will treat this as an error - really it needs to be fixed as it indicates a problem.
1717              throw new ExpectationException(
1718                  'Page load expectation error: page reloads are already been watched for.', $session);
1719          }
1720  
1721          $this->pageloaddetectionrunning = true;
1722  
1723          $this->execute_script(
1724              'var span = document.createElement("span");
1725              span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
1726              span.setAttribute("style", "display: none;");
1727              document.body.appendChild(span);'
1728          );
1729      }
1730  
1731      /**
1732       * Verify that a new page has loaded (or the same page has reloaded) since the
1733       * last "I start watching to see if a new page loads" step.
1734       *
1735       * @Given /^a new page should have loaded since I started watching$/
1736       */
1737      public function a_new_page_should_have_loaded_since_i_started_watching() {
1738          $session = $this->getSession();
1739  
1740          // Make sure page load tracking was started.
1741          if (!$this->pageloaddetectionrunning) {
1742              throw new ExpectationException(
1743                  'Page load expectation error: page load tracking was not started.', $session);
1744          }
1745  
1746          // As the node is inserted by code above it is either there or not, and we do not need spin and it is safe
1747          // to use the native API here which is great as exception handling (the alternative is slow).
1748          if ($session->getPage()->find('xpath', $this->get_page_load_xpath())) {
1749              // We don't want to find this node, if we do we have an error.
1750              throw new ExpectationException(
1751                  'Page load expectation error: a new page has not been loaded when it should have been.', $session);
1752          }
1753  
1754          // Cancel the tracking of pageloaddetectionrunning.
1755          $this->pageloaddetectionrunning = false;
1756      }
1757  
1758      /**
1759       * Verify that a new page has not loaded (or the same page has reloaded) since the
1760       * last "I start watching to see if a new page loads" step.
1761       *
1762       * @Given /^a new page should not have loaded since I started watching$/
1763       */
1764      public function a_new_page_should_not_have_loaded_since_i_started_watching() {
1765          $session = $this->getSession();
1766  
1767          // Make sure page load tracking was started.
1768          if (!$this->pageloaddetectionrunning) {
1769              throw new ExpectationException(
1770                  'Page load expectation error: page load tracking was not started.', $session);
1771          }
1772  
1773          // We use our API here as we can use the exception handling provided by it.
1774          $this->find(
1775              'xpath',
1776              $this->get_page_load_xpath(),
1777              new ExpectationException(
1778                  'Page load expectation error: A new page has been loaded when it should not have been.',
1779                  $this->getSession()
1780              )
1781          );
1782      }
1783  
1784      /**
1785       * Helper used by {@link a_new_page_should_have_loaded_since_i_started_watching}
1786       * and {@link a_new_page_should_not_have_loaded_since_i_started_watching}
1787       * @return string xpath expression.
1788       */
1789      protected function get_page_load_xpath() {
1790          return "//span[@data-rel = '" . self::PAGE_LOAD_DETECTION_STRING . "']";
1791      }
1792  
1793      /**
1794       * Wait unit user press Enter/Return key. Useful when debugging a scenario.
1795       *
1796       * @Then /^(?:|I )pause(?:| scenario execution)$/
1797       */
1798      public function i_pause_scenario_execution() {
1799          $message = "<colour:lightYellow>Paused. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.";
1800          behat_util::pause($this->getSession(), $message);
1801      }
1802  
1803      /**
1804       * Presses a given button in the browser.
1805       * NOTE: Phantomjs and browserkit driver reloads page while navigating back and forward.
1806       *
1807       * @Then /^I press the "(back|forward|reload)" button in the browser$/
1808       * @param string $button the button to press.
1809       * @throws ExpectationException
1810       */
1811      public function i_press_in_the_browser($button) {
1812          $session = $this->getSession();
1813  
1814          if ($button == 'back') {
1815              $session->back();
1816          } else if ($button == 'forward') {
1817              $session->forward();
1818          } else if ($button == 'reload') {
1819              $session->reload();
1820          } else {
1821              throw new ExpectationException('Unknown browser button.', $session);
1822          }
1823      }
1824  
1825      /**
1826       * Send key presses to the browser without first changing focusing, or applying the key presses to a specific
1827       * element.
1828       *
1829       * Example usage of this step:
1830       *     When I type "Penguin"
1831       *
1832       * @When    I type :keys
1833       * @param   string $keys The key, or list of keys, to type
1834       */
1835      public function i_type(string $keys): void {
1836          // Certain keys, such as the newline character, must be converted to the appropriate character code.
1837          // Without this, keys will behave differently depending on the browser.
1838          $keylist = array_map(function($key): string {
1839              switch ($key) {
1840                  case "\n":
1841                      return behat_keys::ENTER;
1842                  default:
1843                      return $key;
1844              }
1845          }, str_split($keys));
1846          behat_base::type_keys($this->getSession(), $keylist);
1847      }
1848  
1849      /**
1850       * Press a named or character key with an optional set of modifiers.
1851       *
1852       * Supported named keys are:
1853       * - up
1854       * - down
1855       * - left
1856       * - right
1857       * - pageup|page_up
1858       * - pagedown|page_down
1859       * - home
1860       * - end
1861       * - insert
1862       * - delete
1863       * - backspace
1864       * - escape
1865       * - enter
1866       * - tab
1867       *
1868       * You can also use a single character for the key name e.g. 'Ctrl C'.
1869       *
1870       * Supported moderators are:
1871       * - shift
1872       * - ctrl
1873       * - alt
1874       * - meta
1875       *
1876       * Example usage of this new step:
1877       *     When I press the up key
1878       *     When I press the space key
1879       *     When I press the shift tab key
1880       *
1881       * Multiple moderator keys can be combined using the '+' operator, for example:
1882       *     When I press the ctrl+shift enter key
1883       *     When I press the ctrl + shift enter key
1884       *
1885       * @When    /^I press the (?P<modifiers_string>.* )?(?P<key_string>.*) key$/
1886       * @param   string $modifiers A list of keyboard modifiers, separated by the `+` character
1887       * @param   string $key The name of the key to press
1888       */
1889      public function i_press_named_key(string $modifiers, string $key): void {
1890          behat_base::require_javascript_in_session($this->getSession());
1891  
1892          $keys = [];
1893  
1894          foreach (explode('+', $modifiers) as $modifier) {
1895              switch (strtoupper(trim($modifier))) {
1896                  case '':
1897                      break;
1898                  case 'SHIFT':
1899                      $keys[] = behat_keys::SHIFT;
1900                      break;
1901                  case 'CTRL':
1902                      $keys[] = behat_keys::CONTROL;
1903                      break;
1904                  case 'ALT':
1905                      $keys[] = behat_keys::ALT;
1906                      break;
1907                  case 'META':
1908                      $keys[] = behat_keys::META;
1909                      break;
1910                  default:
1911                      throw new \coding_exception("Unknown modifier key '$modifier'}");
1912              }
1913          }
1914  
1915          $modifier = trim($key);
1916          switch (strtoupper($key)) {
1917              case 'UP':
1918                  $keys[] = behat_keys::ARROW_UP;
1919                  break;
1920              case 'DOWN':
1921                  $keys[] = behat_keys::ARROW_DOWN;
1922                  break;
1923              case 'LEFT':
1924                  $keys[] = behat_keys::ARROW_LEFT;
1925                  break;
1926              case 'RIGHT':
1927                  $keys[] = behat_keys::ARROW_RIGHT;
1928                  break;
1929              case 'HOME':
1930                  $keys[] = behat_keys::HOME;
1931                  break;
1932              case 'END':
1933                  $keys[] = behat_keys::END;
1934                  break;
1935              case 'INSERT':
1936                  $keys[] = behat_keys::INSERT;
1937                  break;
1938              case 'BACKSPACE':
1939                  $keys[] = behat_keys::BACKSPACE;
1940                  break;
1941              case 'DELETE':
1942                  $keys[] = behat_keys::DELETE;
1943                  break;
1944              case 'PAGEUP':
1945              case 'PAGE_UP':
1946                  $keys[] = behat_keys::PAGE_UP;
1947                  break;
1948              case 'PAGEDOWN':
1949              case 'PAGE_DOWN':
1950                  $keys[] = behat_keys::PAGE_DOWN;
1951                  break;
1952              case 'ESCAPE':
1953                  $keys[] = behat_keys::ESCAPE;
1954                  break;
1955              case 'ENTER':
1956                  $keys[] = behat_keys::ENTER;
1957                  break;
1958              case 'TAB':
1959                  $keys[] = behat_keys::TAB;
1960                  break;
1961              case 'SPACE':
1962                  $keys[] = behat_keys::SPACE;
1963                  break;
1964              case 'MULTIPLY':
1965                  $keys[] = behat_keys::MULTIPLY;
1966                  break;
1967              default:
1968                  // You can enter a single ASCII character (e.g. a letter) to directly type that key.
1969                  if (strlen($key) === 1) {
1970                      $keys[] = strtolower($key);
1971                  } else {
1972                      throw new \coding_exception("Unknown key '$key'}");
1973                  }
1974          }
1975  
1976          behat_base::type_keys($this->getSession(), $keys);
1977      }
1978  
1979      /**
1980       * Trigger a keydown event for a key on a specific element.
1981       *
1982       * @When /^I press key "(?P<key_string>(?:[^"]|\\")*)" in "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
1983       * @param string $key either char-code or character itself,
1984       *               may optionally be prefixed with ctrl-, alt-, shift- or meta-
1985       * @param string $element Element we look for
1986       * @param string $selectortype The type of what we look for
1987       * @throws DriverException
1988       * @throws ExpectationException
1989       */
1990      public function i_press_key_in_element($key, $element, $selectortype) {
1991          if (!$this->running_javascript()) {
1992              throw new DriverException('Key down step is not available with Javascript disabled');
1993          }
1994          // Gets the node based on the requested selector type and locator.
1995          $node = $this->get_selected_node($selectortype, $element);
1996          $modifier = null;
1997          $validmodifiers = array('ctrl', 'alt', 'shift', 'meta');
1998          $char = $key;
1999          if (strpos($key, '-')) {
2000              list($modifier, $char) = preg_split('/-/', $key, 2);
2001              $modifier = strtolower($modifier);
2002              if (!in_array($modifier, $validmodifiers)) {
2003                  throw new ExpectationException(sprintf('Unknown key modifier: %s.', $modifier),
2004                      $this->getSession());
2005              }
2006          }
2007          if (is_numeric($char)) {
2008              $char = (int)$char;
2009          }
2010  
2011          $node->keyDown($char, $modifier);
2012          $node->keyPress($char, $modifier);
2013          $node->keyUp($char, $modifier);
2014      }
2015  
2016      /**
2017       * Press tab key on a specific element.
2018       *
2019       * @When /^I press tab key in "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)"$/
2020       * @param string $element Element we look for
2021       * @param string $selectortype The type of what we look for
2022       * @throws DriverException
2023       * @throws ExpectationException
2024       */
2025      public function i_post_tab_key_in_element($element, $selectortype) {
2026          if (!$this->running_javascript()) {
2027              throw new DriverException('Tab press step is not available with Javascript disabled');
2028          }
2029          // Gets the node based on the requested selector type and locator.
2030          $node = $this->get_selected_node($selectortype, $element);
2031          $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
2032          $this->execute('behat_general::i_press_named_key', ['', 'tab']);
2033      }
2034  
2035      /**
2036       * Checks if database family used is using one of the specified, else skip. (mysql, postgres, mssql, oracle, etc.)
2037       *
2038       * @Given /^database family used is one of the following:$/
2039       * @param TableNode $databasefamilies list of database.
2040       * @return void.
2041       * @throws \Moodle\BehatExtension\Exception\SkippedException
2042       */
2043      public function database_family_used_is_one_of_the_following(TableNode $databasefamilies) {
2044          global $DB;
2045  
2046          $dbfamily = $DB->get_dbfamily();
2047  
2048          // Check if used db family is one of the specified ones. If yes then return.
2049          foreach ($databasefamilies->getRows() as $dbfamilytocheck) {
2050              if ($dbfamilytocheck[0] == $dbfamily) {
2051                  return;
2052              }
2053          }
2054  
2055          throw new \Moodle\BehatExtension\Exception\SkippedException();
2056      }
2057  
2058      /**
2059       * Checks if given plugin is installed, and skips the current scenario if not.
2060       *
2061       * @Given the :plugin plugin is installed
2062       * @param string $plugin frankenstyle plugin name, e.g. 'filter_embedquestion'.
2063       * @throws \Moodle\BehatExtension\Exception\SkippedException
2064       */
2065      public function plugin_is_installed(string $plugin): void {
2066          $path = core_component::get_component_directory($plugin);
2067          if (!is_readable($path . '/version.php')) {
2068              throw new \Moodle\BehatExtension\Exception\SkippedException(
2069                      'Skipping this scenario because the ' . $plugin . ' is not installed.');
2070          }
2071      }
2072  
2073      /**
2074       * Checks focus is with the given element.
2075       *
2076       * @Then /^the focused element is( not)? "(?P<node_string>(?:[^"]|\\")*)" "(?P<node_selector_string>[^"]*)"$/
2077       * @param string $not optional step verifier
2078       * @param string $nodeelement Element identifier
2079       * @param string $nodeselectortype Element type
2080       * @throws DriverException If not using JavaScript
2081       * @throws ExpectationException
2082       */
2083      public function the_focused_element_is($not, $nodeelement, $nodeselectortype) {
2084          if (!$this->running_javascript()) {
2085              throw new DriverException('Checking focus on an element requires JavaScript');
2086          }
2087  
2088          $element = $this->find($nodeselectortype, $nodeelement);
2089          $xpath = addslashes_js($element->getXpath());
2090          $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
2091                  document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
2092          $targetisfocused = $this->evaluate_script($script);
2093          if ($not == ' not') {
2094              if ($targetisfocused) {
2095                  throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
2096              }
2097          } else {
2098              if (!$targetisfocused) {
2099                  throw new ExpectationException("$nodeelement $nodeselectortype is not focused", $this->getSession());
2100              }
2101          }
2102      }
2103  
2104      /**
2105       * Checks focus is with the given element.
2106       *
2107       * @Then /^the focused element is( not)? "(?P<n>(?:[^"]|\\")*)" "(?P<ns>[^"]*)" in the "(?P<c>(?:[^"]|\\")*)" "(?P<cs>[^"]*)"$/
2108       * @param string $not string optional step verifier
2109       * @param string $element Element identifier
2110       * @param string $selectortype Element type
2111       * @param string $nodeelement Element we look in
2112       * @param string $nodeselectortype The type of selector where we look in
2113       * @throws DriverException If not using JavaScript
2114       * @throws ExpectationException
2115       */
2116      public function the_focused_element_is_in_the($not, $element, $selectortype, $nodeelement, $nodeselectortype) {
2117          if (!$this->running_javascript()) {
2118              throw new DriverException('Checking focus on an element requires JavaScript');
2119          }
2120          $element = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement);
2121          $xpath = addslashes_js($element->getXpath());
2122          $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
2123                  document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
2124          $targetisfocused = $this->evaluate_script($script);
2125          if ($not == ' not') {
2126              if ($targetisfocused) {
2127                  throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
2128              }
2129          } else {
2130              if (!$targetisfocused) {
2131                  throw new ExpectationException("$nodeelement $nodeselectortype is not focused", $this->getSession());
2132              }
2133          }
2134      }
2135  
2136      /**
2137       * Manually press tab key.
2138       *
2139       * @When /^I press( shift)? tab$/
2140       * @param string $shift string optional step verifier
2141       * @throws DriverException
2142       */
2143      public function i_manually_press_tab($shift = '') {
2144          if (empty($shift)) {
2145              $this->execute('behat_general::i_press_named_key', ['', 'tab']);
2146          } else {
2147              $this->execute('behat_general::i_press_named_key', ['shift', 'tab']);
2148          }
2149      }
2150  
2151      /**
2152       * Trigger click on node via javascript instead of actually clicking on it via pointer.
2153       * This function resolves the issue of nested elements.
2154       *
2155       * @When /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" skipping visibility check$/
2156       * @param string $element
2157       * @param string $selectortype
2158       */
2159      public function i_click_on_skipping_visibility_check($element, $selectortype) {
2160  
2161          // Gets the node based on the requested selector type and locator.
2162          $node = $this->get_selected_node($selectortype, $element);
2163          $this->js_trigger_click($node);
2164      }
2165  
2166      /**
2167       * Checks, that the specified element contains the specified text a certain amount of times.
2168       * When running Javascript tests it also considers that texts may be hidden.
2169       *
2170       * @Then /^I should see "(?P<elementscount_number>\d+)" occurrences of "(?P<text_string>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
2171       * @throws ElementNotFoundException
2172       * @throws ExpectationException
2173       * @param int    $elementscount How many occurrences of the element we look for.
2174       * @param string $text
2175       * @param string $element Element we look in.
2176       * @param string $selectortype The type of element where we are looking in.
2177       */
2178      public function i_should_see_occurrences_of_in_element($elementscount, $text, $element, $selectortype) {
2179  
2180          // Getting the container where the text should be found.
2181          $container = $this->get_selected_node($selectortype, $element);
2182  
2183          // Looking for all the matching nodes without any other descendant matching the
2184          // same xpath (we are using contains(., ....).
2185          $xpathliteral = behat_context_helper::escape($text);
2186          $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" .
2187                  "[count(descendant::*[contains(., $xpathliteral)]) = 0]";
2188  
2189          $nodes = $this->find_all('xpath', $xpath, false, $container);
2190  
2191          if ($this->running_javascript()) {
2192              $nodes = array_filter($nodes, function($node) {
2193                  return $node->isVisible();
2194              });
2195          }
2196  
2197          if ($elementscount != count($nodes)) {
2198              throw new ExpectationException('Found '.count($nodes).' elements in column. Expected '.$elementscount,
2199                      $this->getSession());
2200          }
2201      }
2202  
2203      /**
2204       * Checks, that the specified element contains the specified node type a certain amount of times.
2205       * When running Javascript tests it also considers that texts may be hidden.
2206       *
2207       * @Then /^I should see "(?P<elementscount_number>\d+)" node occurrences of type "(?P<node_type>(?:[^"]|\\")*)" in the "(?P<element_string>(?:[^"]|\\")*)" "(?P<text_selector_string>[^"]*)"$/
2208       * @throws ElementNotFoundException
2209       * @throws ExpectationException
2210       * @param int    $elementscount How many occurrences of the element we look for.
2211       * @param string $nodetype
2212       * @param string $element Element we look in.
2213       * @param string $selectortype The type of element where we are looking in.
2214       */
2215      public function i_should_see_node_occurrences_of_type_in_element(int $elementscount, string $nodetype, string $element, string $selectortype) {
2216  
2217          // Getting the container where the text should be found.
2218          $container = $this->get_selected_node($selectortype, $element);
2219  
2220          $xpath = "/descendant-or-self::$nodetype [count(descendant::$nodetype) = 0]";
2221  
2222          $nodes = $this->find_all('xpath', $xpath, false, $container);
2223  
2224          if ($this->running_javascript()) {
2225              $nodes = array_filter($nodes, function($node) {
2226                  return $node->isVisible();
2227              });
2228          }
2229  
2230          if ($elementscount != count($nodes)) {
2231              throw new ExpectationException('Found '.count($nodes).' elements in column. Expected '.$elementscount,
2232                  $this->getSession());
2233          }
2234      }
2235  
2236      /**
2237       * Manually press enter key.
2238       *
2239       * @When /^I press enter/
2240       * @throws DriverException
2241       */
2242      public function i_manually_press_enter() {
2243          $this->execute('behat_general::i_press_named_key', ['', 'enter']);
2244      }
2245  
2246      /**
2247       * Visit a local URL relative to the behat root.
2248       *
2249       * @When I visit :localurl
2250       *
2251       * @param string|moodle_url $localurl The URL relative to the behat_wwwroot to visit.
2252       */
2253      public function i_visit($localurl): void {
2254          $localurl = new moodle_url($localurl);
2255          $this->getSession()->visit($this->locate_path($localurl->out_as_local_url(false)));
2256      }
2257  
2258      /**
2259       * Increase the webdriver timeouts.
2260       *
2261       * This should be reset between scenarios, or can be called again to decrease the timeouts.
2262       *
2263       * @Given I mark this test as slow setting a timeout factor of :factor
2264       */
2265      public function i_mark_this_test_as_long_running(int $factor = 2): void {
2266          $this->set_test_timeout_factor($factor);
2267      }
2268  
2269      /**
2270       * Click on a dynamic tab to load its content
2271       *
2272       * @Given /^I click on the "(?P<tab_string>(?:[^"]|\\")*)" dynamic tab$/
2273       *
2274       * @param string $tabname
2275       */
2276      public function i_click_on_the_dynamic_tab(string $tabname): void {
2277          $xpath = "//*[@id='dynamictabs-tabs'][descendant::a[contains(text(), '" . $this->escape($tabname) . "')]]";
2278          $this->execute('behat_general::i_click_on_in_the',
2279              [$tabname, 'link', $xpath, 'xpath_element']);
2280      }
2281  
2282      /**
2283       * Enable an specific plugin.
2284       *
2285       * @When /^I enable "(?P<plugin_string>(?:[^"]|\\")*)" "(?P<plugintype_string>[^"]*)" plugin$/
2286       * @param string $plugin Plugin we look for
2287       * @param string $plugintype The type of the plugin
2288       */
2289      public function i_enable_plugin($plugin, $plugintype) {
2290          $class = core_plugin_manager::resolve_plugininfo_class($plugintype);
2291          $class::enable_plugin($plugin, true);
2292      }
2293  
2294      /**
2295       * Set the default text editor to the named text editor.
2296       *
2297       * @Given the default editor is set to :editor
2298       * @param string $editor
2299       * @throws ExpectationException If the specified editor is not available.
2300       */
2301      public function the_default_editor_is_set_to(string $editor): void {
2302          global $CFG;
2303  
2304          // Check if the provided editor is available.
2305          if (!array_key_exists($editor, editors_get_available())) {
2306              throw new ExpectationException(
2307                  "Unable to set the editor to {$editor} as it is not installed. The available editors are: " .
2308                      implode(', ', array_keys(editors_get_available())),
2309                  $this->getSession()
2310              );
2311          }
2312  
2313          // Make the provided editor the default one in $CFG->texteditors by
2314          // moving it to the first [editor],atto,tiny,textarea on the list.
2315          $list = explode(',', $CFG->texteditors);
2316          array_unshift($list, $editor);
2317          $list = array_unique($list);
2318  
2319          // Set the list new list of editors.
2320          set_config('texteditors', implode(',', $list));
2321      }
2322  
2323      /**
2324       * Allow to check for minimal Moodle version.
2325       *
2326       * @Given the site is running Moodle version :minversion or higher
2327       * @param string $minversion The minimum version of Moodle required (inclusive).
2328       */
2329      public function the_site_is_running_moodle_version_or_higher(string $minversion): void {
2330          global $CFG;
2331          require_once($CFG->libdir . '/environmentlib.php');
2332  
2333          $currentversion = normalize_version(get_config('', 'release'));
2334  
2335          if (version_compare($currentversion, $minversion, '<')) {
2336              throw new Moodle\BehatExtension\Exception\SkippedException(
2337                  'Site must be running Moodle version ' . $minversion . ' or higher'
2338              );
2339          }
2340      }
2341  
2342      /**
2343       * Allow to check for maximum Moodle version.
2344       *
2345       * @Given the site is running Moodle version :maxversion or lower
2346       * @param string $maxversion The maximum version of Moodle required (inclusive).
2347       */
2348      public function the_site_is_running_moodle_version_or_lower(string $maxversion): void {
2349          global $CFG;
2350          require_once($CFG->libdir . '/environmentlib.php');
2351  
2352          $currentversion = normalize_version(get_config('', 'release'));
2353  
2354          if (version_compare($currentversion, $maxversion, '>')) {
2355              throw new Moodle\BehatExtension\Exception\SkippedException(
2356                  'Site must be running Moodle version ' . $maxversion . ' or lower'
2357              );
2358          }
2359      }
2360  
2361      /**
2362       * Check that the page title contains a given string.
2363       *
2364       * @Given the page title should contain ":title"
2365       * @param string $title The string that should be present on the page title.
2366       */
2367      public function the_page_title_should_contain(string $title): void {
2368          $session = $this->getSession();
2369          if ($this->running_javascript()) {
2370              // 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.
2371              $actualtitle = $session->evaluateScript("return document.title");
2372          } else {
2373              $titleelement = $session->getPage()->find('css', 'head title');
2374              if ($titleelement === null) {
2375                  // Throw an exception if a page title is not present on the page.
2376                  throw new ElementNotFoundException(
2377                      $this->getSession(),
2378                      '<title> element',
2379                      'css',
2380                      'head title'
2381                  );
2382              }
2383              $actualtitle = $titleelement->getText();
2384          }
2385  
2386          if (!str_contains($actualtitle, $title)) {
2387              throw new ExpectationException(
2388                  "'$title' was not found from the current page title '$actualtitle'",
2389                  $session
2390              );
2391          }
2392      }
2393  }