Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

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