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