MDL-63977 Behat: Wait longer for app login
[moodle.git] / lib / tests / behat / behat_app.php
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/>.
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  */
26 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
28 require_once(__DIR__ . '/../../behat/behat_base.php');
30 use Behat\Mink\Exception\DriverException;
31 use Behat\Mink\Exception\ExpectationException;
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;
45     /** @var string URL for running Ionic server */
46     protected $ionicurl = '';
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     }
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 }
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         }
84         // Restart the browser and set its size.
85         $this->getSession()->restart();
86         $this->resize_window('360x720', true);
88         // Go to page and prepare browser for app.
89         $this->prepare_browser($this->ionicurl);
90     }
92     /**
93      * Checks the Behat setup - tags and configuration.
94      *
95      * @throws DriverException
96      */
97     protected function check_behat_setup() {
98         global $CFG;
100         // Check JavaScript is enabled.
101         if (!$this->running_javascript()) {
102             throw new DriverException('The app requires JavaScript.');
103         }
105         // Check the config settings are defined.
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.');
108         }
109     }
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;
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         }
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);
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         }
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         }
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     }
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;
179         if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) {
180             // Use supplied Ionic server which should already be running.
181             $url = $CFG->behat_ionic_wwwroot;
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.
187             $path = realpath($CFG->behat_ionic_dirroot);
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]);
203             // Get pid - we will need this to kill the process.
204             $status = proc_get_status($process);
205             $pid = $status['pid'];
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                 }
219                 $stdoutlog .= $line;
221                 if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) {
222                     $url = $matches[1];
223                     break;
224                 }
225             }
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             }
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     }
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]);
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     }
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;
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);
294         // Run the scripts to install Moodle 'pending' checks.
295         $this->getSession()->executeScript(
296                 file_get_contents(__DIR__ . '/app_behat_runtime.js'));
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                 }, self::EXTENDED_TIMEOUT, 30);
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         }
319         // Continue only after JS finishes.
320         $this->wait_for_pending_js();
321     }
323     /**
324      * Carries out the login steps for the app, assuming the user is on the app login page. Called
325      * from behat_auth.php.
326      *
327      * @param string $username Username (and password)
328      * @throws Exception Any error
329      */
330     public function login(string $username) {
331         $this->i_set_the_field_in_the_app('Username', $username);
332         $this->i_set_the_field_in_the_app('Password', $username);
334         // Note there are two 'Log in' texts visible (the title and the button) so we have to use
335         // a 'near' value here.
336         $this->i_press_near_in_the_app('Log in', 'Forgotten');
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);
348         // Wait for JS to finish as well.
349         $this->wait_for_pending_js();
350     }
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) {
361             $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' .
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     }
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)  {
379             $result = $this->getSession()->evaluateScript('return window.behat.closePopup();');
380             if ($result !== 'OK') {
381                 throw new DriverException('Error closing popup - ' . $result);
382             }
383             return true;
384         });
385         $this->wait_for_pending_js();
386     }
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) {
399         $this->press($text);
400     }
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) {
415         $this->press($text, $near);
416     }
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 = '') {
431         $this->spin(function($context, $args) use ($text, $near) {
432             if ($near !== '') {
433                 $nearbit = ', "' . addslashes_js($near) . '"';
434             } else {
435                 $nearbit = '';
436             }
437             $result = $context->getSession()->evaluateScript('return window.behat.press("' .
438                     addslashes_js($text) . '"' . $nearbit .');');
439             if ($result !== 'OK') {
440                 throw new DriverException('Error pressing item - ' . $result);
441             }
442             return true;
443         });
444         $this->wait_for_pending_js();
445     }
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) {
460             $result = $this->getSession()->evaluateScript('return window.behat.setField("' .
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     }
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) {
482             $result = $this->getSession()->evaluateScript('return window.behat.getHeader();');
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     }
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     }
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     }