Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
   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   * Mobile/desktop app steps definitions.
  19   *
  20   * @package core
  21   * @category test
  22   * @copyright 2018 The Open University
  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\Mink\Exception\DriverException;
  31  use Behat\Mink\Exception\ExpectationException;
  32  
  33  /**
  34   * Mobile/desktop app steps definitions.
  35   *
  36   * @package core
  37   * @category test
  38   * @copyright 2018 The Open University
  39   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class behat_app extends behat_base {
  42      /** @var stdClass Object with data about launched Ionic instance (if any) */
  43      protected static $ionicrunning = null;
  44  
  45      /** @var string URL for running Ionic server */
  46      protected $ionicurl = '';
  47  
  48      /**
  49       * Checks if the current OS is Windows, from the point of view of task-executing-and-killing.
  50       *
  51       * @return bool True if Windows
  52       */
  53      protected static function is_windows() : bool {
  54          return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
  55      }
  56  
  57      /**
  58       * Called from behat_hooks when a new scenario starts, if it has the app tag.
  59       *
  60       * This updates Moodle configuration and starts Ionic running, if it isn't already.
  61       */
  62      public function start_scenario() {
  63          $this->check_behat_setup();
  64          $this->fix_moodle_setup();
  65          $this->ionicurl = $this->start_or_reuse_ionic();
  66  }
  67  
  68      /**
  69       * Opens the Moodle app in the browser.
  70       *
  71       * Requires JavaScript.
  72       *
  73       * @Given /^I enter the app$/
  74       * @throws DriverException Issue with configuration or feature file
  75       * @throws dml_exception Problem with Moodle setup
  76       * @throws ExpectationException Problem with resizing window
  77       */
  78      public function i_enter_the_app() {
  79          // Check the app tag was set.
  80          if (!$this->has_tag('app')) {
  81              throw new DriverException('Requires @app tag on scenario or feature.');
  82          }
  83  
  84          // Restart the browser and set its size.
  85          $this->getSession()->restart();
  86          $this->resize_window('360x720', true);
  87  
  88          if (empty($this->ionicurl)) {
  89              $this->ionicurl = $this->start_or_reuse_ionic();
  90          }
  91  
  92          // Go to page and prepare browser for app.
  93          $this->prepare_browser($this->ionicurl);
  94      }
  95  
  96      /**
  97       * Checks the Behat setup - tags and configuration.
  98       *
  99       * @throws DriverException
 100       */
 101      protected function check_behat_setup() {
 102          global $CFG;
 103  
 104          // Check JavaScript is enabled.
 105          if (!$this->running_javascript()) {
 106              throw new DriverException('The app requires JavaScript.');
 107          }
 108  
 109          // Check the config settings are defined.
 110          if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) {
 111              throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.');
 112          }
 113      }
 114  
 115      /**
 116       * Fixes the Moodle admin settings to allow mobile app use (if not already correct).
 117       *
 118       * @throws dml_exception If there is any problem changing Moodle settings
 119       */
 120      protected function fix_moodle_setup() {
 121          global $CFG, $DB;
 122  
 123          // Configure Moodle settings to enable app web services.
 124          if (!$CFG->enablewebservices) {
 125              set_config('enablewebservices', 1);
 126          }
 127          if (!$CFG->enablemobilewebservice) {
 128              set_config('enablemobilewebservice', 1);
 129          }
 130  
 131          // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role.
 132          $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']);
 133          $systemcontext = \context_system::instance();
 134          role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW);
 135          role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW);
 136  
 137          // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour
 138          // in Behat with regard to config variables that aren't defined in a settings.php, the
 139          // value in $CFG here may reflect a previous run, so get it direct from the database
 140          // instead.
 141          $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING);
 142          if (empty($field)) {
 143              $protocols = [];
 144          } else {
 145              $protocols = explode(',', $field);
 146          }
 147          if (!in_array('rest', $protocols)) {
 148              $protocols[] = 'rest';
 149              set_config('webserviceprotocols', implode(',', $protocols));
 150          }
 151  
 152          // Enable mobile service.
 153          require_once($CFG->dirroot . '/webservice/lib.php');
 154          $webservicemanager = new webservice();
 155          $service = $webservicemanager->get_external_service_by_shortname(
 156                  MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST);
 157          if (!$service->enabled) {
 158              $service->enabled = 1;
 159              $webservicemanager->update_external_service($service);
 160          }
 161  
 162          // If installed, also configure local_mobile plugin to enable additional features service.
 163          $localplugins = core_component::get_plugin_list('local');
 164          if (array_key_exists('mobile', $localplugins)) {
 165              $service = $webservicemanager->get_external_service_by_shortname(
 166                      'local_mobile', MUST_EXIST);
 167              if (!$service->enabled) {
 168                  $service->enabled = 1;
 169                  $webservicemanager->update_external_service($service);
 170              }
 171          }
 172      }
 173  
 174      /**
 175       * Starts an Ionic server if necessary, or uses an existing one.
 176       *
 177       * @return string URL to Ionic server
 178       * @throws DriverException If there's a system error starting Ionic
 179       */
 180      protected function start_or_reuse_ionic() {
 181          global $CFG;
 182  
 183          if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) {
 184              // Use supplied Ionic server which should already be running.
 185              $url = $CFG->behat_ionic_wwwroot;
 186          } else if (self::$ionicrunning) {
 187              // Use existing Ionic instance launched previously.
 188              $url = self::$ionicrunning->url;
 189          } else {
 190              // Open Ionic process in relevant path.
 191              $path = realpath($CFG->behat_ionic_dirroot);
 192              $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log';
 193              $prefix = '';
 194              // Except on Windows, use 'exec' so that we get the pid of the actual Node process
 195              // and not the shell it uses to execute. You can't do exec on Windows; there is a
 196              // bypass_shell option but it is not the same thing and isn't usable here.
 197              if (!self::is_windows()) {
 198                  $prefix = 'exec ';
 199              }
 200              $process = proc_open($prefix . 'ionic serve --no-interactive --no-open',
 201                      [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path);
 202              if ($process === false) {
 203                  throw new DriverException('Error starting Ionic process');
 204              }
 205              fclose($pipes[0]);
 206  
 207              // Get pid - we will need this to kill the process.
 208              $status = proc_get_status($process);
 209              $pid = $status['pid'];
 210  
 211              // Read data from stdout until the server comes online.
 212              // Note: On Windows it is impossible to read simultaneously from stderr and stdout
 213              // because stream_select and non-blocking I/O don't work on process pipes, so that is
 214              // why stderr was redirected to a file instead. Also, this code is simpler.
 215              $url = null;
 216              $stdoutlog = '';
 217              while (true) {
 218                  $line = fgets($pipes[1], 4096);
 219                  if ($line === false) {
 220                      break;
 221                  }
 222  
 223                  $stdoutlog .= $line;
 224  
 225                  if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) {
 226                      $url = $matches[1];
 227                      break;
 228                  }
 229              }
 230  
 231              // If it failed, close the pipes and the process.
 232              if (!$url) {
 233                  fclose($pipes[1]);
 234                  proc_close($process);
 235                  $logpath = $CFG->dataroot . '/behat/ionic-start.log';
 236                  $stderrlog = file_get_contents($stderrfile);
 237                  @unlink($stderrfile);
 238                  file_put_contents($logpath,
 239                          "Ionic startup log from " . date('c') .
 240                          "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog");
 241                  throw new DriverException('Unable to start Ionic. See ' . $logpath);
 242              }
 243  
 244              // Remember the URL, so we can reuse it next time, and other details so we can kill
 245              // the process.
 246              self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes,
 247                      'pid' => $pid];
 248              $url = self::$ionicrunning->url;
 249          }
 250          return $url;
 251      }
 252  
 253      /**
 254       * Closes Ionic (if it was started) at end of test suite.
 255       *
 256       * @AfterSuite
 257       */
 258      public static function close_ionic() {
 259          if (self::$ionicrunning) {
 260              fclose(self::$ionicrunning->pipes[1]);
 261  
 262              if (self::is_windows()) {
 263                  // Using proc_terminate here does not work. It terminates the process but not any
 264                  // other processes it might have launched. Instead, we need to use an OS-specific
 265                  // mechanism to kill the process and children based on its pid.
 266                  exec('taskkill /F /T /PID ' . self::$ionicrunning->pid);
 267              } else {
 268                  // On Unix this actually works, although only due to the 'exec' command inserted
 269                  // above.
 270                  proc_terminate(self::$ionicrunning->process);
 271              }
 272              self::$ionicrunning = null;
 273          }
 274      }
 275  
 276      /**
 277       * Goes to the app page and then sets up some initial JavaScript so we can use it.
 278       *
 279       * @param string $url App URL
 280       * @throws DriverException If the app fails to load properly
 281       */
 282      protected function prepare_browser(string $url) {
 283          global $CFG;
 284  
 285          // Visit the Ionic URL and wait for it to load.
 286          $this->getSession()->visit($url);
 287          $this->spin(
 288                  function($context, $args) {
 289                      $title = $context->getSession()->getPage()->find('xpath', '//title');
 290                      if ($title) {
 291                          $text = $title->getHtml();
 292                          if ($text === 'Moodle Desktop') {
 293                              return true;
 294                          }
 295                      }
 296                      throw new DriverException('Moodle app not found in browser');
 297                  }, false, 60);
 298  
 299          // Run the scripts to install Moodle 'pending' checks.
 300          $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js'));
 301  
 302          // Wait until the site login field appears OR the main page.
 303          $situation = $this->spin(
 304                  function($context, $args) {
 305                      $page = $context->getSession()->getPage();
 306  
 307                      $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]');
 308                      if ($element) {
 309                          // Wait for the onboarding modal to open, if any.
 310                          $this->wait_for_pending_js();
 311                          $element = $page->find('xpath', '//page-core-login-site-onboarding');
 312                          if ($element) {
 313                              $this->i_press_in_the_app('Skip');
 314                          }
 315  
 316                          return 'login';
 317                      }
 318  
 319                      $element = $page->find('xpath', '//page-core-mainmenu');
 320                      if ($element) {
 321                          return 'mainpage';
 322                      }
 323                      throw new DriverException('Moodle app login URL prompt not found');
 324                  }, behat_base::get_extended_timeout(), 60);
 325  
 326          // If it's the login page, we automatically fill in the URL and leave it on the user/pass
 327          // page. If it's the main page, we just leave it there.
 328          if ($situation === 'login') {
 329              $this->i_set_the_field_in_the_app('campus.example.edu', $CFG->wwwroot);
 330              $this->i_press_in_the_app('Connect!');
 331          }
 332  
 333          // Continue only after JS finishes.
 334          $this->wait_for_pending_js();
 335      }
 336  
 337      /**
 338       * Carries out the login steps for the app, assuming the user is on the app login page. Called
 339       * from behat_auth.php.
 340       *
 341       * @param string $username Username (and password)
 342       * @throws Exception Any error
 343       */
 344      public function login(string $username) {
 345          $this->i_set_the_field_in_the_app('Username', $username);
 346          $this->i_set_the_field_in_the_app('Password', $username);
 347  
 348          // Note there are two 'Log in' texts visible (the title and the button) so we have to use
 349          // a 'near' value here.
 350          $this->i_press_near_in_the_app('Log in', 'Forgotten');
 351  
 352          // Wait until the main page appears.
 353          $this->spin(
 354                  function($context, $args) {
 355                      $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
 356                      if ($mainmenu) {
 357                          return 'mainpage';
 358                      }
 359                      throw new DriverException('Moodle app main page not loaded after login');
 360                  }, false, 30);
 361  
 362          // Wait for JS to finish as well.
 363          $this->wait_for_pending_js();
 364      }
 365  
 366      /**
 367       * Presses standard buttons in the app.
 368       *
 369       * @Given /^I press the (?P<button_name>back|main menu|page menu) button in the app$/
 370       * @param string $button Button type
 371       * @throws DriverException If the button push doesn't work
 372       */
 373      public function i_press_the_standard_button_in_the_app(string $button) {
 374          $this->spin(function($context, $args) use ($button) {
 375              $result = $this->evaluate_script("return window.behat.pressStandard('{$button}');");
 376              if ($result !== 'OK') {
 377                  throw new DriverException('Error pressing standard button - ' . $result);
 378              }
 379              return true;
 380          });
 381          $this->wait_for_pending_js();
 382      }
 383  
 384      /**
 385       * Closes a popup by clicking on the 'backdrop' behind it.
 386       *
 387       * @Given /^I close the popup in the app$/
 388       * @throws DriverException If there isn't a popup to close
 389       */
 390      public function i_close_the_popup_in_the_app() {
 391          $this->spin(function($context, $args)  {
 392              $result = $this->evaluate_script("return window.behat.closePopup();");
 393              if ($result !== 'OK') {
 394                  throw new DriverException('Error closing popup - ' . $result);
 395              }
 396              return true;
 397          });
 398          $this->wait_for_pending_js();
 399      }
 400  
 401      /**
 402       * Clicks on / touches something that is visible in the app.
 403       *
 404       * Note it is difficult to use the standard 'click on' or 'press' steps because those do not
 405       * distinguish visible items and the app always has many non-visible items in the DOM.
 406       *
 407       * @Given /^I press "(?P<text_string>(?:[^"]|\\")*)" in the app$/
 408       * @param string $text Text identifying click target
 409       * @throws DriverException If the press doesn't work
 410       */
 411      public function i_press_in_the_app(string $text) {
 412          $this->press($text);
 413      }
 414  
 415      /**
 416       * Clicks on / touches something that is visible in the app, near some other text.
 417       *
 418       * This is the same as the other step, but when there are multiple matches, it picks the one
 419       * nearest (in DOM terms) the second text. The second text should be an exact match, or a partial
 420       * match that only has one result.
 421       *
 422       * @Given /^I press "(?P<text_string>(?:[^"]|\\")*)" near "(?P<nearby_string>(?:[^"]|\\")*)" in the app$/
 423       * @param string $text Text identifying click target
 424       * @param string $near Text identifying a nearby unique piece of text
 425       * @throws DriverException If the press doesn't work
 426       */
 427      public function i_press_near_in_the_app(string $text, string $near) {
 428          $this->press($text, $near);
 429      }
 430  
 431      /**
 432       * Clicks on / touches something that is visible in the app, near some other text.
 433       *
 434       * If the $near is specified then when there are multiple matches, it picks the one
 435       * nearest (in DOM terms) $near. $near should be an exact match, or a partial match that only
 436       * has one result.
 437       *
 438       * @param behat_base $base Behat context
 439       * @param string $text Text identifying click target
 440       * @param string $near Text identifying a nearby unique piece of text
 441       * @throws DriverException If the press doesn't work
 442       */
 443      protected function press(string $text, string $near = '') {
 444          $this->spin(function($context, $args) use ($text, $near) {
 445              if ($near !== '') {
 446                  $nearbit = ', "' . addslashes_js($near) . '"';
 447              } else {
 448                  $nearbit = '';
 449              }
 450              $result = $this->evaluate_script('return window.behat.press("' .
 451                      addslashes_js($text) . '"' . $nearbit .');');
 452              if ($result !== 'OK') {
 453                  throw new DriverException('Error pressing item - ' . $result);
 454              }
 455              return true;
 456          });
 457          $this->wait_for_pending_js();
 458      }
 459  
 460      /**
 461       * Sets a field to the given text value in the app.
 462       *
 463       * Currently this only works for input fields which must be identified using a partial or
 464       * exact match on the placeholder text.
 465       *
 466       * @Given /^I set the field "(?P<field_name>(?:[^"]|\\")*)" to "(?P<text_string>(?:[^"]|\\")*)" in the app$/
 467       * @param string $field Text identifying field
 468       * @param string $value Value for field
 469       * @throws DriverException If the field set doesn't work
 470       */
 471      public function i_set_the_field_in_the_app(string $field, string $value) {
 472          $this->spin(function($context, $args) use ($field, $value) {
 473              $result = $this->evaluate_script('return window.behat.setField("' .
 474                      addslashes_js($field) . '", "' . addslashes_js($value) . '");');
 475              if ($result !== 'OK') {
 476                  throw new DriverException('Error setting field - ' . $result);
 477              }
 478              return true;
 479          });
 480          $this->wait_for_pending_js();
 481      }
 482  
 483      /**
 484       * Checks that the current header stripe in the app contains the expected text.
 485       *
 486       * This can be used to see if the app went to the expected page.
 487       *
 488       * @Then /^the header should be "(?P<text_string>(?:[^"]|\\")*)" in the app$/
 489       * @param string $text Expected header text
 490       * @throws DriverException If the header can't be retrieved
 491       * @throws ExpectationException If the header text is different to the expected value
 492       */
 493      public function the_header_should_be_in_the_app(string $text) {
 494          $result = $this->spin(function($context, $args) {
 495              $result = $this->evaluate_script('return window.behat.getHeader();');
 496              if (substr($result, 0, 3) !== 'OK:') {
 497                  throw new DriverException('Error getting header - ' . $result);
 498              }
 499              return $result;
 500          });
 501          $header = substr($result, 3);
 502          if (trim($header) !== trim($text)) {
 503              throw new ExpectationException('The header text was not as expected: \'' . $header . '\'',
 504                      $this->getSession()->getDriver());
 505          }
 506      }
 507  
 508      /**
 509       * Switches to a newly-opened browser tab.
 510       *
 511       * This assumes the app opened a new tab.
 512       *
 513       * @Given /^I switch to the browser tab opened by the app$/
 514       * @throws DriverException If there aren't exactly 2 tabs open
 515       */
 516      public function i_switch_to_the_browser_tab_opened_by_the_app() {
 517          $names = $this->getSession()->getWindowNames();
 518          if (count($names) !== 2) {
 519              throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
 520          }
 521          $this->getSession()->switchToWindow($names[1]);
 522      }
 523  
 524      /**
 525       * Closes the current browser tab.
 526       *
 527       * This assumes it was opened by the app and you will now get back to the app.
 528       *
 529       * @Given /^I close the browser tab opened by the app$/
 530       * @throws DriverException If there aren't exactly 2 tabs open
 531       */
 532      public function i_close_the_browser_tab_opened_by_the_app() {
 533          $names = $this->getSession()->getWindowNames();
 534          if (count($names) !== 2) {
 535              throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
 536          }
 537          $this->execute_script('window.close()');
 538          $this->getSession()->switchToWindow($names[0]);
 539      }
 540  
 541      /**
 542       * Switch navigator online mode.
 543       *
 544       * @Given /^I switch offline mode to "(?P<offline_string>(?:[^"]|\\")*)"$/
 545       * @param string $offline New value for navigator online mode
 546       * @throws DriverException If the navigator.online mode is not available
 547       */
 548      public function i_switch_offline_mode(string $offline) {
 549          $this->execute_script('appProvider.setForceOffline(' . $offline . ');');
 550      }
 551  }