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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body